├── README.md └── plugin └── pct.vim /README.md: -------------------------------------------------------------------------------- 1 | pct-vim 2 | ======= 3 | 4 | # PCT-VIM - Precise Code Tracking Vim plugin 5 | by @d0c_s4vage 6 | 7 | ## Intro 8 | 9 | This plugin is the vim implementation of the PCT method developed 10 | by @tmanning. It is intended to assist in code auditing by enabling one to 11 | annotate read-only source code from a text editor. This plugin 12 | is the vim implementation. See @tmanning for the textmate implementation. 13 | 14 | PCT uses an sqlite3 database to store and query audited ranges of code 15 | and notes that were taken/added for line ranges. 16 | 17 | ## Getting started 18 | 19 | Follow the steps below to initialize the database: 20 | 21 | 1. Install [dependencies](#dependencies) 22 | 2. Source pct.vim 23 | 3. Open a file in a project that you wish to audit 24 | 4. Run the command below to initialize the database: 25 | `:PctInit` 26 | 5. Begin auditing! 27 | 28 | Be aware that the plugin treats the root dirname of the database path as 29 | the root of the project. Any files in subfolders of the root project path 30 | will be included in reports and are considered "part of the project". 31 | 32 | ## Dependencies 33 | 34 | * Vim 35 | * Python 36 | * `peewee` python module (`pip install peewee`) 37 | 38 | ## Key mappings 39 | 40 | * Review 41 | * `[r` - mark the current/selected line(s) as having been reviewed 42 | * Generic Comments (Annotations) 43 | * `[a` - annotate the current/selected line(s) with a single-line generic comment 44 | * `[A` - annotate the current/selected line(s) with a multi-line generic comment 45 | * Findings 46 | * `[f` - annotate the current/selected line(s) with a single-line finding 47 | * `[F` - annotate the current/selected line(s) with a multi-line finding 48 | * Todos 49 | * `[t` - annotate the current/selected line(s) with a single-line todo 50 | * `[T` - annotate the current/selected line(s) with a multi-line todo 51 | * Annotation Modification 52 | * `[d` - delete an annotation on the current line 53 | * Reports/Listings 54 | * `[R` - toggle the report of the current project 55 | * `[h` - show a recent history of notes/reviewed source files 56 | * Other 57 | * `[o` - open the file under the cursor in a new readonly tab (useful for reports) 58 | * Annotation Navigation 59 | * `[n` - jump to the next annotation in the current file 60 | * `[N` - jump to the previous annotation in the current file 61 | * `[?` - display the current annotation(s) in a vertically-split window 62 | 63 | ## Notes 64 | 65 | Note that the only differentiation between annotations/findings/todos is the 66 | existence of certain keywords in the annotation. Todos contain the word 67 | "TODO" in the text, findings contain the word "FINDING" in the text, and 68 | generic annotations don't contain either. 69 | 70 | ## Known Issues 71 | * sometimes there are issues when viewing existing notes while scrolling through a split file 72 | 73 | ## Future 74 | 75 | * ability to mark files as out-of-scope 76 | * ability to edit annotations 77 | 78 | ## Screenshots 79 | 80 | Lines marked as reviewed 81 | 82 | ![Reviewed lines](http://i.imgur.com/xN8uduB.png) 83 | 84 | A simple note/annotation 85 | 86 | ![Simple note/annotation](http://i.imgur.com/SHEMVEK.png) 87 | 88 | A todo 89 | 90 | ![A todo](http://i.imgur.com/F3eqsU9.png) 91 | 92 | A finding 93 | 94 | ![A finding](http://i.imgur.com/zr0xoDV.png) 95 | 96 | Report and History 97 | 98 | ![PCT Report and History](http://i.imgur.com/m8G7eno.png) 99 | -------------------------------------------------------------------------------- /plugin/pct.vim: -------------------------------------------------------------------------------- 1 | " # PCT - Precise Code Tracking 2 | " @d0c_s4vage 3 | " 4 | " ## Intro 5 | " 6 | " This plugin is intended to assist in code auditing by enabling one to 7 | " annotate read-only source code from a text editor. This plugin 8 | " is the vim implementation. See @tmanning for the textmate implementation. 9 | " 10 | " PCT uses an sqlite3 database to store and query audited ranges of code 11 | " and notes that were taken/added for line ranges. 12 | " 13 | " ## Getting started 14 | " 15 | " Follow the steps below to initialize the database: 16 | " 17 | " 1. Source pct.vim 18 | " 2. Open a file in a project that you wish to audit 19 | " 3. Run the command below to initialize the database: 20 | " :PctInit 21 | " 4. Begin auditing! 22 | " 23 | " ## Key mappings 24 | " 25 | " * Review 26 | " * [r - mark the current/selected line(s) as having been reviewed 27 | " * Generic Comments (Annotations) 28 | " * [a - annotate the current/selected line(s) with a single-line generic comment 29 | " * [A - annotate the current/selected line(s) with a multi-line generic comment 30 | " * Findings 31 | " * [f - annotate the current/selected line(s) with a single-line finding 32 | " * [F - annotate the current/selected line(s) with a multi-line finding 33 | " * Todos 34 | " * [t - annotate the current/selected line(s) with a single-line todo 35 | " * [T - annotate the current/selected line(s) with a multi-line todo 36 | " * Reports/Listings 37 | " * [R - toggle the report of the current project 38 | " * [h - show a recent history of notes/reviewed source files 39 | " * Other 40 | " * [o - open the file under the cursor in a new readonly tab (useful for reports) 41 | " * Annotation Navigation 42 | " * [n - jump to the next annotation in the current file 43 | " * [N - jump to the previous annotation in the current file 44 | " 45 | " ## Notes 46 | " 47 | " Note that the only differentiation between annotations/findings/todos is the 48 | " existince of certain keywords in the annotation. Todos contain the word 49 | " "TODO" in the text, findings contain the word "FINDING" in the text, and 50 | " generic annotations don't contain either. 51 | " 52 | " ## Known Issues 53 | " * sometimes there are issues when viewing existing notes while scrolling through 54 | " a split file 55 | " 56 | " ## Future 57 | " 58 | " * ability to mark files as out-of-scope 59 | " * ability to edit/delete annotations 60 | " 61 | 62 | 63 | " --------------------------------------------- 64 | " --------------------------------------------- 65 | 66 | if exists('g:loaded_pct') && g:loaded_pct 67 | finish 68 | endif 69 | 70 | function! DefinePct() 71 | python3 <')") 95 | DB = None 96 | DB_PATH = None 97 | DB_NAME = "pct.sqlite" 98 | 99 | SHOW_ANNOTATIONS = True 100 | 101 | def _input(message = 'input'): 102 | vim.command('call inputsave()') 103 | vim.command("let user_input = input('" + message + ": ')") 104 | vim.command('call inputrestore()') 105 | return vim.eval('user_input') 106 | 107 | class Colors: 108 | HEADER = '\033[95m' 109 | OKBLUE = '\033[94m' 110 | OKGREEN = '\033[92m' 111 | WARNING = '\033[93m' 112 | FAIL = '\033[91m' 113 | ENDC = '\033[0m' 114 | 115 | def buff_clear(): 116 | vim.command("silent %delete _") 117 | 118 | def buff_puts(msg, clear=True): 119 | vim.command("setlocal modifiable") 120 | if clear: 121 | buff_clear() 122 | count = 0 123 | for line in msg.split("\n"): 124 | vim.command("let tmp='" + line.replace("'", "' . \"'\" . '") + "'") 125 | if count == 0 and clear: 126 | vim.command("silent 0put=tmp") 127 | else: 128 | vim.command("silent put=tmp") 129 | count += 1 130 | 131 | @contextmanager 132 | def restore_cursor(): 133 | cursor = vim.current.window.cursor 134 | yield 135 | vim.current.window.cursor = cursor 136 | 137 | @contextmanager 138 | def v_restore_cursor(): 139 | yield 140 | vim.command('execute "normal! gv\\"') 141 | 142 | def mode(): 143 | return vim.eval("mode()") 144 | 145 | def is_visual(m=None): 146 | if m is None: 147 | m = mode() 148 | return m in ["v", "V", "CTRL-V"] 149 | 150 | def winnr(): 151 | """ 152 | """ 153 | return int(vim.eval("winnr()")) 154 | 155 | def win_goto(nr): 156 | vim.command("execute '{nr}wincmd w'".format(nr=nr)) 157 | 158 | def buffwinnr(name): 159 | """ 160 | """ 161 | return int(vim.eval("bufwinnr('" + name + "')")) 162 | 163 | def buff_close(name, delete=False): 164 | """ 165 | Close the buffer with name `name` 166 | """ 167 | bufnr = buffwinnr(name) 168 | if bufnr == -1: 169 | return 170 | vim.command("execute '{nr}wincmd w'".format(nr=bufnr)) 171 | 172 | if delete: 173 | vim.command("bd!") 174 | else: 175 | vim.command("close") 176 | 177 | def buff_exists(name): 178 | """ 179 | Return true or false if the buffer named `name` exists 180 | """ 181 | return buffwinnr(name) != -1 182 | 183 | def buff_goto(name): 184 | """ 185 | Goto to the buff with name `name` 186 | """ 187 | nr = buffwinnr(name) 188 | if nr == -1: 189 | return 190 | 191 | vim.command("{}wincmd w".format(nr)) 192 | 193 | def root_path(): 194 | """ 195 | """ 196 | return os.path.abspath(os.path.dirname(DB.database)) 197 | 198 | def reduce_path(path): 199 | """ 200 | Return a path that does not have double separators, etc. Sometimes double 201 | separators occurs when using cscope/ctags/other tools 202 | """ 203 | return path.replace(os.path.sep * 2, os.path.sep) 204 | 205 | def norm_path(path): 206 | """ 207 | Return a path that is relative to the database! 208 | """ 209 | res = os.path.relpath(path, os.path.dirname(DB.database)) 210 | res = reduce_path(res) 211 | return res 212 | 213 | def file_is_reviewable(path): 214 | """ 215 | Return True/False if the file exists and is in the current project, 216 | IF a valid DB exists 217 | """ 218 | if path is None: 219 | return False 220 | 221 | if isinstance(path, PctModels.Path): 222 | return True 223 | 224 | if path is None: 225 | return False 226 | 227 | if not os.path.exists(path): 228 | return False 229 | 230 | if os.path.isdir(path): 231 | return False 232 | 233 | if not DB: 234 | return False 235 | 236 | if norm_path(path).startswith(".."): 237 | return False 238 | 239 | return True 240 | 241 | def rev_norm_path(path): 242 | """ 243 | Return a normalized path relative to the cwd 244 | """ 245 | abs_path = os.path.abspath(os.path.join(os.path.dirname(DB.database), path)) 246 | return os.path.relpath(abs_path, os.path.abspath(os.getcwd())) 247 | 248 | def _msg(char, msg, pre="[", post="]", color=None): 249 | if False and color is not None: 250 | pre = color + pre 251 | post = post + Colors.ENDC 252 | 253 | for line in msg.split("\n"): 254 | print("{pre}{char}{post} {line}".format( 255 | pre=pre, 256 | char=char, 257 | post=post, 258 | line=line 259 | )) 260 | 261 | def err(msg): 262 | _msg("X", msg, color=Colors.FAIL) 263 | 264 | def log(msg): 265 | _msg(" ", msg, color=Colors.OKBLUE) 266 | 267 | def info(msg): 268 | _msg("+", msg, color=Colors.OKBLUE) 269 | 270 | def warn(msg): 271 | _msg("!", msg, color=Colors.WARNING) 272 | 273 | def ok(msg): 274 | _msg("✓", msg, color=Colors.OKGREEN) 275 | 276 | def find_db(max_levels=15): 277 | db_name = DB_NAME 278 | curr_level = 0 279 | curr_path = db_name 280 | 281 | while curr_level < max_levels: 282 | if os.path.exists(curr_path): 283 | return curr_path 284 | curr_level += 1 285 | curr_path = os.path.join("..", curr_path) 286 | 287 | return None 288 | 289 | def create_db(dest_path): 290 | global DB 291 | 292 | if dest_path is None: 293 | return 294 | 295 | DB = SqliteDatabase(dest_path, threadlocals=True) 296 | 297 | class BaseModel(Model): 298 | class Meta: 299 | database = DB 300 | 301 | class Path(BaseModel): 302 | path = CharField(unique=True) 303 | timestamp = DateTimeField(default=datetime.datetime.now) 304 | line_count = IntegerField() 305 | 306 | class Review(BaseModel): 307 | path = ForeignKeyField(Path) 308 | timestamp = DateTimeField(default=datetime.datetime.now) 309 | line_start = IntegerField() 310 | line_end = IntegerField() 311 | column_start = IntegerField(default=0) 312 | column_end = IntegerField(default=0) 313 | 314 | class Note(BaseModel): 315 | path = ForeignKeyField(Path) 316 | review = ForeignKeyField(Review) 317 | timestamp = DateTimeField(default=datetime.datetime.now) 318 | note = TextField() 319 | 320 | class Scope(BaseModel): 321 | path = ForeignKeyField(Path) 322 | scope_flag = BooleanField() 323 | 324 | PctModels.Path = Path 325 | PctModels.Review = Review 326 | PctModels.Note = Note 327 | PctModels.Scope = Scope 328 | 329 | DB.connect() 330 | try: 331 | DB.create_tables([Path, Review, Note, Scope]) 332 | except Exception as e: 333 | if "already exists" in str(e): 334 | pass 335 | else: 336 | raise 337 | 338 | def prompt_for_db_path(): 339 | warn("Could not find the database, where should it be created?") 340 | 341 | curr_path = os.path.join(os.getcwd(), "HACK") 342 | opts = [] 343 | old_path = None 344 | while len(opts) < 7 and old_path != curr_path: 345 | old_path = curr_path 346 | curr_path = os.path.abspath(os.path.join(curr_path, "..")) 347 | db_path = os.path.join(curr_path, DB_NAME) 348 | opts.append(db_path) 349 | 350 | warn("Annotation location options:") 351 | for x in range(len(opts)): 352 | opt = opts[x] 353 | warn(" %s - %s" % (x, opt)) 354 | 355 | choice = _input("Where would you like to create the database? (0-%d)" % (len(opts)-1)) 356 | warn("") 357 | 358 | try: 359 | choice = int(choice) 360 | except: 361 | err("Invalid choice") 362 | return 363 | 364 | if not (0 <= choice < len(opts)): 365 | err("Invalid choice") 366 | return None 367 | 368 | return opts[choice] 369 | 370 | def init_db(create=True): 371 | """ 372 | """ 373 | found_db = None 374 | if not create: 375 | found_db = find_db() 376 | if found_db is None and create: 377 | db_path = prompt_for_db_path() 378 | 379 | if db_path is None: 380 | err("Invalid db path") 381 | return 382 | 383 | info("Using database at {}".format(db_path)) 384 | found_db = db_path 385 | 386 | if create or found_db is not None: 387 | create_db(found_db) 388 | ok("found annotations database at %s" % found_db) 389 | vim.command("call DefineAutoCommands()") 390 | 391 | def get_path(path, create=True): 392 | """ 393 | The path _should_ be a string, but in case it's an instance of a Path ORM 394 | object, just return it. 395 | 396 | MAKE SURE THE PATH IS RELATIVE TO THE PROJECT ROOT! 397 | """ 398 | if isinstance(path, PctModels.Path): 399 | return path 400 | 401 | # normalize the path 402 | normd_path = norm_path(path) 403 | 404 | try: 405 | existing_path = PctModels.Path.get(PctModels.Path.path == normd_path) 406 | return existing_path 407 | except: 408 | if create: 409 | return add_path(path) 410 | else: 411 | return None 412 | 413 | def add_path(path): 414 | """ 415 | """ 416 | # TODO - need to make sure all paths are relative to the database, else 417 | # they won't be portable! 418 | path = norm_path(path) 419 | 420 | line_count=0 421 | with open(os.path.join(os.path.dirname(DB.database), path)) as f: 422 | data = f.read() 423 | line_count = data.count("\n") 424 | if data[-1] == "\n": 425 | line_count -= 1 426 | 427 | new_path = PctModels.Path(path=path, line_count=line_count) 428 | new_path.save() 429 | 430 | update_status(vim.current.buffer.name) 431 | 432 | return new_path 433 | 434 | def get_review(path, line_start, line_end, column_start=0, column_end=0, create=True): 435 | """ 436 | Get or create a review at path `path`, line, and columns. Path can be either 437 | an ORM object or a string 438 | """ 439 | if type(path) in [str]: 440 | path = get_path(path) 441 | 442 | try: 443 | rv = PctModels.Review 444 | review = PctModels.Review.get( 445 | rv.path == path, 446 | rv.line_start == line_start, 447 | rv.line_end == line_end, 448 | rv.column_start == column_start, 449 | rv.column_end == column_end 450 | ) 451 | return review 452 | except: 453 | if create: 454 | return add_review(path, line_start, line_end, column_start, column_end) 455 | else: 456 | return None 457 | 458 | def get_reviews(path): 459 | """ 460 | Return all the reviews in the file 461 | """ 462 | path = get_path(path) 463 | reviews = PctModels.Review.select().where( 464 | PctModels.Review.path == path 465 | ) 466 | return reviews 467 | 468 | def add_review(path, line_start, line_end, column_start=0, column_end=0): 469 | """ 470 | """ 471 | if not file_is_reviewable(path): 472 | return 473 | 474 | path = get_path(path) 475 | review = PctModels.Review( 476 | path = path, 477 | line_start = line_start, 478 | line_end = line_end, 479 | column_start = column_start, 480 | column_end = column_end 481 | ) 482 | review.save() 483 | 484 | update_status(vim.current.buffer.name) 485 | 486 | return review 487 | 488 | def get_notes(path): 489 | """ 490 | Get all notes in a path 491 | """ 492 | path = get_path(path) 493 | notes = PctModels.Note.select().where( 494 | PctModels.Note.path == path 495 | ) 496 | return notes 497 | 498 | def add_note(path, line_start, line_end, note="", column_start=0, column_end=0): 499 | """ 500 | """ 501 | path = get_path(path) 502 | review = get_review(path, line_start, line_end, column_start, column_end) 503 | new_note = PctModels.Note( 504 | path = path, 505 | review = review, 506 | note = note 507 | ) 508 | new_note.save() 509 | 510 | update_status(vim.current.buffer.name) 511 | 512 | return new_note 513 | 514 | def show_sign(id, sign_type, line, filename=None, buffer=None): 515 | if filename is None: 516 | filename = vim.current.buffer.name 517 | 518 | if filename is None and buf is None: 519 | filename = vim.current.buffer.name 520 | 521 | which = None 522 | if filename is not None: 523 | which = "file=" + filename 524 | elif buf is not None: 525 | which = "buf=" + str(buf) 526 | 527 | command = "sign place {id} line={line} name={sign_type} {which}".format( 528 | id = id, 529 | line = line, 530 | sign_type = sign_type, 531 | which = which 532 | ) 533 | vim.command(command) 534 | 535 | def show_review_signs(review, filename=None): 536 | count = 0 537 | for line in range(review.line_start, review.line_end+1, 1): 538 | id = review.id * 10000 + count 539 | show_sign(id, "sign_reviewed", line, filename=filename) 540 | count += 1 541 | 542 | def show_note_signs(note, filename=None): 543 | count = 0 544 | sign_type = "sign_note" 545 | if "FINDING" in note.note: 546 | sign_type = "sign_finding" 547 | elif "TODO" in note.note: 548 | sign_type = "sign_todo" 549 | 550 | try: 551 | test = note.review.id 552 | except: 553 | warn('deleted bad note instance') 554 | note.delete_instance() 555 | return 556 | 557 | for line in range(note.review.line_start, note.review.line_end+1, 1): 558 | id = note.id * 20000 + count 559 | show_sign(id, sign_type, line, filename=filename) 560 | count += 1 561 | 562 | def buff_enter(): 563 | update_status() 564 | 565 | # update the report every time it is looked at 566 | if vim.current.buffer.name is not None and os.path.basename(vim.current.buffer.name) == report_name: 567 | report() 568 | 569 | def update_status(bufname=None): 570 | if bufname is None: 571 | bufname = vim.current.buffer.name 572 | 573 | try: 574 | if not file_is_reviewable(bufname): 575 | new_status = "%f" 576 | else: 577 | status = get_status(bufname, no_filename=True) 578 | new_status = "%f - " + status.replace("%", "%%") 579 | new_status = new_status.replace(" ", "\\ ") 580 | 581 | vim.command("set statusline=" + new_status) 582 | except: 583 | pass 584 | 585 | def unload_signs_buffer(bufname): 586 | command = "sign unplace *" 587 | vim.command(command) 588 | 589 | def load_signs_buffer(bufname): 590 | if not SHOW_ANNOTATIONS: 591 | return 592 | 593 | # only worry about files that exist! 594 | if bufname is None or not os.path.exists(bufname): 595 | return 596 | 597 | unload_signs_buffer(bufname) 598 | 599 | path = get_path(bufname) 600 | reviews = get_reviews(path) 601 | notes = get_notes(path) 602 | 603 | for review in reviews: 604 | show_review_signs(review, filename=bufname) 605 | 606 | for note in notes: 607 | show_note_signs(note, filename=bufname) 608 | 609 | update_status(bufname) 610 | 611 | def load_signs_new_buffer(): 612 | # use this instead of `expand("%")`!!! % doens't work with BufAdd 613 | curr_file = vim.eval("expand('')") 614 | 615 | # do not add files that are outside of the current project! 616 | if not file_is_reviewable(curr_file): 617 | return 618 | 619 | load_signs_buffer(curr_file) 620 | 621 | def set_initial_review_mark(): 622 | # mark the first line in the file with the "c" mark 623 | vim.command('execute "normal! mc"') 624 | 625 | def load_signs_all_buffers(): 626 | if DB is not None: 627 | for buff in vim.buffers: 628 | load_signs_buffer(buff.name) 629 | vim.command("redraw!") 630 | 631 | # ---------------------------------------- 632 | # ---------------------------------------- 633 | 634 | def create_scratch(text, fit_to_contents=True, return_to_orig=False, scratch_name="__THE_AUDIT__", retnr=-1, set_buftype=True, width=50, wrap=False, modify=False): 635 | if buff_exists(scratch_name): 636 | buff_close(scratch_name, delete=True) 637 | 638 | if fit_to_contents: 639 | max_line_width = max(len(max(text.split("\n"), key=len)) + 4, 30) 640 | else: 641 | max_line_width = width 642 | 643 | orig_buffnr = winnr() 644 | orig_range_start = vim.current.range.start 645 | orig_range_end = vim.current.range.end 646 | 647 | vim.command("silent keepalt botright vertical {width}split {name}".format( 648 | width=max_line_width, 649 | name=scratch_name 650 | )) 651 | count = 0 652 | 653 | buff_puts(text) 654 | 655 | vim.command("let b:retnr = " + str(retnr)) 656 | 657 | # these must be done AFTER the text has been set (because of 658 | # the nomodifiable flag) 659 | if set_buftype: 660 | vim.command("setlocal buftype=nofile") 661 | 662 | vim.command("setlocal bufhidden=hide") 663 | vim.command("setlocal nobuflisted") 664 | vim.command("setlocal noswapfile") 665 | vim.command("setlocal noro") 666 | vim.command("setlocal nolist") 667 | vim.command("setlocal winfixwidth") 668 | vim.command("setlocal textwidth=0") 669 | vim.command("setlocal nospell") 670 | vim.command("setlocal nonumber") 671 | if wrap: 672 | vim.command("setlocal wrap") 673 | 674 | if not modify: 675 | vim.command("setlocal nomodifiable") 676 | 677 | if return_to_orig: 678 | win_goto(orig_buffnr) 679 | 680 | def multi_input(placeholder): 681 | tmp = tempfile.NamedTemporaryFile(delete=False) 682 | tmp.write(bytes(placeholder, "utf-8")) 683 | tmp.close() 684 | 685 | create_scratch(placeholder, width=80, set_buftype=False, scratch_name=tmp.name, modify=True) 686 | 687 | def notes(search=None): 688 | if search is not None: 689 | notes = PctModels.Note.select().where( 690 | PctModels.Note.note % search 691 | ) 692 | else: 693 | notes = PctModels.Note.select() 694 | 695 | lines = [] 696 | for note in notes: 697 | cwd_path = rev_norm_path(note.review.path.path) 698 | 699 | lines.append("{path}:{line_start}\n{indented_note}".format( 700 | path=cwd_path, 701 | line_start=note.review.line_start, 702 | indented_note="\n".join([" "+l for l in note.note.split("\n")]) 703 | )) 704 | 705 | text = "THE AUDIT NOTES:\n\n" + "\n".join(lines) 706 | create_scratch(text) 707 | 708 | def get_status(path, no_filename=False, filename_max=0, raw=False): 709 | path = get_path(path) 710 | reviews = get_reviews(path) 711 | finfo = { 712 | "path": path, 713 | "reviews":[], 714 | "todos":0, 715 | "notes":0, 716 | "findings":0 717 | } 718 | 719 | for review in reviews: 720 | finfo["reviews"].append(review) 721 | notes = PctModels.Note.select().where(PctModels.Note.review == review) 722 | for note in notes: 723 | if "TODO" in note.note: 724 | finfo["todos"] += 1 725 | if "FINDING" in note.note: 726 | finfo["findings"] += 1 727 | else: 728 | finfo["notes"] += 1 729 | 730 | 731 | # calc percent coverage 732 | all_lines = set() 733 | for review in finfo["reviews"]: 734 | if review.line_start == review.line_end: 735 | all_lines.add(review.line_start) 736 | else: 737 | all_lines = all_lines.union(set(range(review.line_start, review.line_end))) 738 | if finfo["path"].line_count > 0: 739 | coverage = len(all_lines) / float(finfo["path"].line_count) 740 | else: 741 | coverage = 1 742 | 743 | finfo["coverage"] = coverage 744 | 745 | status_text = None 746 | 747 | if no_filename: 748 | status_text = ("%3d%%, %2d fndg, %2d todo, %2d note" % ( 749 | finfo["coverage"] * 100, 750 | finfo["findings"], 751 | finfo["todos"], 752 | finfo["notes"] 753 | )) 754 | else: 755 | status_text = (("%-" + str(filename_max) + "s - %3d%%, %2d fndg, %2d todo, %2d note") % ( 756 | rev_norm_path(path.path), 757 | finfo["coverage"] * 100, 758 | finfo["findings"], 759 | finfo["todos"], 760 | finfo["notes"] 761 | )) 762 | 763 | if raw: 764 | return { 765 | "text": status_text, 766 | "info": finfo 767 | } 768 | else: 769 | return status_text 770 | 771 | def _report_info(): 772 | statuses = [] 773 | max_len = 0 774 | for path in PctModels.Path.select(): 775 | path_len = len(rev_norm_path(path.path)) 776 | if path_len > max_len: 777 | max_len = path_len 778 | 779 | existing = {} 780 | for path in PctModels.Path.select(): 781 | status = get_status(path, filename_max=max_len, raw=True) 782 | 783 | c = status["info"]["coverage"] 784 | hl = None 785 | if c == 1.0: 786 | hl = "sign_audit_100_complete" 787 | elif c >= 0.9: 788 | hl = "sign_audit_good" 789 | elif c > 0.40: 790 | hl = "sign_audit_in_progress" 791 | else: 792 | hl = "sign_audit_not_much" 793 | 794 | statuses.append({ 795 | "status": status["text"], 796 | "hl": hl 797 | }) 798 | existing[path.path] = True 799 | 800 | unopened = [] 801 | d = os.path.join(root_path(), "***") 802 | for root, dirnames, filenames in os.walk(root_path()): 803 | for filename in filenames: 804 | proj_file = os.path.join(root, filename) 805 | normd_path = norm_path(proj_file) 806 | if normd_path in existing or not file_is_reviewable(proj_file): 807 | continue 808 | cwd_rel_path = rev_norm_path(normd_path) 809 | unopened.append(cwd_rel_path) 810 | 811 | if len(unopened) > 0: 812 | statuses.append("") 813 | statuses.append("----------------") 814 | statuses.append("--- UNOPENED ---") 815 | statuses.append("----------------") 816 | statuses.append("") 817 | 818 | statuses += unopened 819 | 820 | return statuses 821 | 822 | def toggle_annotations(): 823 | global SHOW_ANNOTATIONS 824 | 825 | if SHOW_ANNOTATIONS: 826 | hide_annotations() 827 | else: 828 | show_annotations() 829 | 830 | SHOW_ANNOTATIONS = not SHOW_ANNOTATIONS 831 | 832 | def hide_annotations(): 833 | pass 834 | 835 | report_name = "__THE_AUDIT_REPORT__" 836 | def toggle_report(): 837 | if buff_exists(report_name): 838 | buff_close(report_name) 839 | return 840 | report() 841 | 842 | def report(): 843 | restore = (vim.current.buffer.name is not None and os.path.basename(vim.current.buffer.name) == report_name) 844 | line,col = vim.current.window.cursor 845 | 846 | report_info = _report_info() 847 | text = [ 848 | "REPORT:", 849 | "", 850 | "" 851 | ] 852 | colors = [] 853 | count = 0 854 | for item in report_info: 855 | if type(item) == str: 856 | text.append(item) 857 | continue 858 | 859 | text.append(item["status"]) 860 | colors.append({ 861 | "line": len(text), 862 | "hl": item["hl"] 863 | }) 864 | count += 1 865 | 866 | create_scratch("\n".join(text), scratch_name=report_name) 867 | 868 | count = 100 869 | for color in colors: 870 | show_sign(count, color["hl"], color["line"], buffer=vim.current.buffer.number) 871 | count += 1 872 | 873 | if restore: 874 | vim.current.window.cursor = (line,col) 875 | 876 | # for some reason vim still loses the current position in the buffer 877 | # (if you go up/down a line, it jumps to the start of the line instead 878 | # of maintaining the current cursor position). 879 | # 880 | # Moving the cursor left/right seems to fix this 881 | vim.command("silent normal! lh") 882 | else: 883 | vim.command("normal! gg") 884 | 885 | # show the current cursor line 886 | vim.command("setlocal cursorline") 887 | 888 | history_name = "__THE_AUDIT_HISTORY__" 889 | def toggle_history(): 890 | if buff_exists(history_name): 891 | buff_close(history_name) 892 | return 893 | 894 | history() 895 | 896 | def history(n=50, match=None): 897 | """ 898 | """ 899 | history = [] 900 | for review in PctModels.Review.select().order_by(PctModels.Review.timestamp.desc()).limit(n): 901 | showed_filename = False 902 | if match is None: 903 | history.append("{}:{} - {}".format(rev_norm_path(review.path.path), review.line_start, review.timestamp)) 904 | showed_filename = True 905 | 906 | notes = PctModels.Note.select().where(PctModels.Note.review == review) 907 | count = 0 908 | for note in notes: 909 | if match is None or match in note.note: 910 | if not showed_filename: 911 | history.append("{}:{}".format(rev_norm_path(review.path.path), review.line_start)) 912 | showed_filename = True 913 | history.append("\tNOTE ({})\n{}".format(note.timestamp, "\n".join("\t\t%s" % (x) for x in note.note.split("\n")))) 914 | 915 | if match is None: 916 | create_scratch("HISTORY:\n\n" + "\n".join(history), scratch_name=history_name) 917 | else: 918 | create_scratch("HISTORY MATCHING " + match + ":\n\n" + "\n".join(history), scratch_name=history_name) 919 | 920 | vim.command("normal! gg") 921 | 922 | def _review_lines(filename, line_start, line_end): 923 | if not file_is_reviewable(vim.current.buffer.name): 924 | return 925 | 926 | review = get_review(filename, line_start, line_end, create=False) 927 | 928 | if review is None: 929 | with v_restore_cursor(): 930 | review = add_review(filename, line_start, line_end) 931 | show_review_signs(review) 932 | ok("marked as reviewed") 933 | else: 934 | review.delete_instance() 935 | ok("unmarked as reviewed") 936 | 937 | load_signs_buffer(vim.current.buffer.name) 938 | 939 | return review 940 | 941 | def review_selection(): 942 | if not file_is_reviewable(vim.current.buffer.name): 943 | return 944 | 945 | rng = vim.current.range 946 | # the ranges are all 0-based 947 | _review_lines(vim.current.buffer.name, rng.start+1, rng.end+1) 948 | 949 | def review_current_line(): 950 | if not file_is_reviewable(vim.current.buffer.name): 951 | return 952 | 953 | # not 0-based! 954 | line,_ = vim.current.window.cursor 955 | _review_lines(vim.current.buffer.name, line, line) 956 | 957 | def note_selection(prefix="", prompt="note", multi=False, start=None, end=None): 958 | if not file_is_reviewable(vim.current.buffer.name): 959 | return 960 | 961 | rng = vim.current.range 962 | 963 | if len(prefix) > 0: 964 | prefix += " " 965 | 966 | if multi: 967 | name = vim.current.buffer.name 968 | commands = [ 969 | "let b:new_note = 1", 970 | "let b:line_start = {start}".format(start=rng.start+1), 971 | "let b:line_end = {end}".format(end=rng.end+1), 972 | "let b:note_bufname = '{name}'".format(name=name), 973 | "let b:retnr = " + str(winnr()) 974 | ] 975 | multi_input(prefix + " ") 976 | vim.command(" | ".join(commands)) 977 | vim.command("startinsert!") 978 | else: 979 | with v_restore_cursor(): 980 | note = _input(prompt) 981 | # the ranges are all 0-based 982 | new_note = add_note(vim.current.buffer.name, rng.start+1, rng.end+1, prefix + note) 983 | show_note_signs(new_note) 984 | show_current_notes() 985 | 986 | ok("added " + prompt) 987 | 988 | def save_note_from_buffer(): 989 | line_start = int(vim.eval("b:line_start")) 990 | line_end = int(vim.eval("b:line_end")) 991 | bufname = vim.eval("b:note_bufname") 992 | 993 | with open(vim.current.buffer.name, "r") as f: 994 | text = f.read() 995 | 996 | new_note = add_note(bufname, line_start, line_end, text) 997 | retnr = int(vim.eval("b:retnr")) 998 | vim.command("close") 999 | if retnr != -1: 1000 | vim.command("{nr}wincmd w".format(nr=retnr)) 1001 | 1002 | show_note_signs(new_note) 1003 | 1004 | def note_current_line(prefix="", prompt="note", multi=False, placeholder=""): 1005 | if not file_is_reviewable(vim.current.buffer.name): 1006 | return 1007 | 1008 | # not 0-based! 1009 | line,_ = vim.current.window.cursor 1010 | curr_file = vim.current.buffer.name 1011 | 1012 | if len(prefix) > 0: 1013 | prefix += " " 1014 | 1015 | if multi: 1016 | name = vim.current.buffer.name 1017 | commands = [ 1018 | "let b:new_note = 1", 1019 | "let b:line_start = {start}".format(start=line), 1020 | "let b:line_end = {end}".format(end=line), 1021 | "let b:note_bufname = '{name}'".format(name=name), 1022 | "let b:retnr = " + str(winnr()) 1023 | ] 1024 | multi_input(prefix + " ") 1025 | vim.command(" | ".join(commands)) 1026 | vim.command("normal! $a") 1027 | else: 1028 | note = _input(prompt) 1029 | # the cursor line number IS NOT zero based 1030 | new_note = add_note(curr_file, line, line, prefix + note) 1031 | show_note_signs(new_note) 1032 | show_current_notes() 1033 | 1034 | ok("added " + prompt) 1035 | 1036 | def cursor_moved(): 1037 | if DB is None: 1038 | return 1039 | 1040 | if not file_is_reviewable(vim.current.buffer.name): 1041 | return 1042 | 1043 | show_current_notes() 1044 | 1045 | if is_auditing: 1046 | filename = vim.current.buffer.name 1047 | line,_ = vim.current.window.cursor 1048 | 1049 | reviews = get_reviews(filename) 1050 | found_review = False 1051 | for review in reviews: 1052 | if review.line_start <= line and review.line_end >= line: 1053 | found_review = True 1054 | break 1055 | 1056 | if not found_review: 1057 | review = add_review(filename, line, line) 1058 | show_review_signs(review) 1059 | 1060 | def get_notes_for_line(filename, line): 1061 | if not file_is_reviewable(filename): 1062 | return [] 1063 | 1064 | try: 1065 | all_notes = get_notes(filename) 1066 | count = 0 1067 | notes = [] 1068 | 1069 | for note in all_notes: 1070 | start = note.review.line_start 1071 | end = note.review.line_end 1072 | if start <= line and end >= line: 1073 | notes.append(note) 1074 | except Exception as e: 1075 | warn("notes exist that aren't tied to a review, deleting them") 1076 | DB.execute_sql("DELETE FROM note where review not in (SELECT id from review)") 1077 | 1078 | return notes 1079 | 1080 | def note_to_text(note): 1081 | start = note.review.line_start 1082 | end = note.review.line_end 1083 | if start != end: 1084 | return "NOTE ({}-{}): {}".format( 1085 | start, 1086 | end, 1087 | note.note 1088 | ) 1089 | else: 1090 | return "NOTE ({}): {}".format( 1091 | start, 1092 | note.note 1093 | ) 1094 | 1095 | def get_note_text_for_line(filename, line): 1096 | if not file_is_reviewable(filename): 1097 | return [] 1098 | 1099 | notes = get_notes_for_line(filename, line) 1100 | text = [] 1101 | for note in notes: 1102 | if len(text) > 0: 1103 | text.append("---------------------") 1104 | text.append(note_to_text(note)) 1105 | 1106 | return text 1107 | 1108 | had_note = False 1109 | note_scratch = "__THE_AUDIT_NOTE__" 1110 | status_line_notes = True 1111 | def show_current_notes(status_line_notes_override=False): 1112 | global had_note 1113 | global status_line_notes 1114 | 1115 | if not SHOW_ANNOTATIONS: 1116 | return 1117 | 1118 | m = mode() 1119 | 1120 | line,_ = vim.current.window.cursor 1121 | filename = vim.current.buffer.name 1122 | orig_bufnr = winnr() 1123 | closed_note = False 1124 | 1125 | # only worry about files that 1126 | if not file_is_reviewable(filename): 1127 | return 1128 | 1129 | text = get_note_text_for_line(filename, line) 1130 | 1131 | if status_line_notes and not status_line_notes_override: 1132 | if len(text) > 0: 1133 | status = text[0].split("\n")[0] 1134 | # if there's more than one note, or the note is a multi-line note, 1135 | # show some indication that there's more to be read 1136 | if len(text) > 1: 1137 | status += " +++" 1138 | elif len(text[0]) > len(status): 1139 | status += " ..." 1140 | info(status) 1141 | else: 1142 | print("") 1143 | else: 1144 | if len(text) > 0: 1145 | create_scratch("\n".join(text), 1146 | fit_to_contents=False, 1147 | return_to_orig=True, 1148 | scratch_name=note_scratch, 1149 | retnr=orig_bufnr, 1150 | wrap=True 1151 | ) 1152 | had_note = True 1153 | 1154 | if len(text) == 0 and buff_exists(note_scratch): 1155 | buff_goto(note_scratch) 1156 | retnr = int(vim.eval("b:retnr")) 1157 | if retnr != -1: 1158 | vim.command("close") 1159 | vim.command("{nr}wincmd w".format(nr=retnr)) 1160 | #if had_note: 1161 | #vim.command("!redraw") 1162 | had_note = False 1163 | closed_note = True 1164 | 1165 | # reselect whatever whas selected in visual mode 1166 | if is_visual(m) and (closed_note or had_note): 1167 | vim.command("normal! gv") 1168 | 1169 | def delete_note_on_line(): 1170 | """ 1171 | Delete the note associated with the current line 1172 | """ 1173 | 1174 | line,_ = vim.current.window.cursor 1175 | filename = vim.current.buffer.name 1176 | orig_bufnr = winnr() 1177 | 1178 | if not file_is_reviewable(filename): 1179 | return 1180 | 1181 | notes = get_notes_for_line(filename, line) 1182 | if len(notes) == 0: 1183 | return 1184 | 1185 | if len(notes) == 1: 1186 | choice = _input("Are you sure you want to delete the current note? (y/n)") 1187 | if choice[0].lower() == "y": 1188 | notes[0].review.delete_instance() 1189 | notes[0].delete_instance() 1190 | ok("Deleted note") 1191 | load_signs_buffer(vim.current.buffer.name) 1192 | 1193 | else: 1194 | idx = 0 1195 | for note in notes: 1196 | text = note_to_text(note) 1197 | warn(" %s - %s" % (idx, text.split("\n")[0])) 1198 | idx += 1 1199 | choice = _input("Which note would you like to delete? (0-%d)" % (len(notes)-1)) 1200 | print("") 1201 | 1202 | try: 1203 | choice = int(choice) 1204 | except: 1205 | err("Invalid choice") 1206 | return 1207 | 1208 | if not (0 <= choice <= len(notes)): 1209 | err("Invalid choice") 1210 | return 1211 | 1212 | notes[choice].review.delete_instance() 1213 | notes[choice].delete_instance() 1214 | print("") 1215 | ok("Deleted note") 1216 | load_signs_buffer(vim.current.buffer.name) 1217 | 1218 | def edit_note_on_line(): 1219 | """ 1220 | Delete the note associated with the current line 1221 | """ 1222 | return 1223 | # this is not ready yet 1224 | 1225 | line,_ = vim.current.window.cursor 1226 | filename = vim.current.buffer.name 1227 | orig_bufnr = winnr() 1228 | 1229 | if not file_is_reviewable(filename): 1230 | return 1231 | 1232 | notes = get_notes_for_line(filename, line) 1233 | if len(notes) == 0: 1234 | return 1235 | 1236 | if len(notes) == 1: 1237 | note = notes[0] 1238 | note_text = note.note 1239 | note.delete_instance() 1240 | 1241 | start = note.review.line_start 1242 | end = note.review.line_end 1243 | else: 1244 | idx = 0 1245 | for note in notes: 1246 | text = note_to_text(note) 1247 | warn(" %s - %s" % (idx, text.split("\n")[0])) 1248 | idx += 1 1249 | choice = _input("Which note would you like to delete? (0-%d)" % (len(notes)-1)) 1250 | print("") 1251 | 1252 | try: 1253 | choice = int(choice) 1254 | except: 1255 | err("Invalid choice") 1256 | return 1257 | 1258 | if not (0 <= choice <= len(notes)): 1259 | err("Invalid choice") 1260 | return 1261 | 1262 | notes[choice].delete_instance() 1263 | print("") 1264 | ok("Deleted note") 1265 | load_signs_buffer(vim.current.buffer.name) 1266 | 1267 | def jump_to_note(direction=1, curr_line=None): 1268 | """ 1269 | Jump to the next note in the file 1270 | """ 1271 | notes = get_notes(vim.current.buffer.name) 1272 | 1273 | down = direction == 1 1274 | 1275 | # searching down 1276 | if down: 1277 | notes = sorted(notes, key=lambda n: n.review.line_start) 1278 | else: 1279 | notes = sorted(notes, key=lambda n: n.review.line_end, reverse=True) 1280 | 1281 | if curr_line is None: 1282 | curr_line,_ = vim.current.window.cursor 1283 | 1284 | dest_line = None 1285 | for note in notes: 1286 | start = note.review.line_start 1287 | end = note.review.line_end 1288 | if down and start > curr_line: 1289 | dest_line = start 1290 | break 1291 | elif not down and end < curr_line: 1292 | dest_line = end 1293 | break 1294 | 1295 | if dest_line is not None: 1296 | vim.current.window.cursor = (dest_line,0) 1297 | show_current_notes() 1298 | elif dest_line is None and len(notes) > 0: 1299 | warn("wrapped to next note") 1300 | if down: 1301 | jump_to_note(direction=direction, curr_line=0) 1302 | else: 1303 | jump_to_note(direction=direction, curr_line=len(vim.current.buffer)) 1304 | 1305 | is_auditing = False 1306 | def toggle_audit(): 1307 | global is_auditing 1308 | is_auditing = not is_auditing 1309 | 1310 | if is_auditing: 1311 | vim.command("hi StatusLine cterm=bold ctermfg=black ctermbg=red") 1312 | else: 1313 | vim.command("hi StatusLine cterm=bold ctermfg=blue ctermbg=white") 1314 | EOF 1315 | endfunction 1316 | call DefinePct() 1317 | 1318 | function! DefineAutoCommands() 1319 | augroup Pct! 1320 | autocmd! 1321 | autocmd BufReadPre * py3 set_initial_review_mark() 1322 | autocmd BufAdd * py3 load_signs_new_buffer() 1323 | autocmd BufEnter * py3 buff_enter() 1324 | autocmd BufWritePost * call MaybeSaveNote() 1325 | autocmd VimEnter * py3 load_signs_all_buffers() 1326 | autocmd CursorMoved * py3 cursor_moved() 1327 | augroup END 1328 | endfunction 1329 | 1330 | py3 init_db(create=False) 1331 | 1332 | " --------------------------------------------- 1333 | " --------------------------------------------- 1334 | 1335 | " mark the selected line as as reviewed 1336 | vmap [r :py3 review_selection() 1337 | nmap [r :py3 review_current_line() 1338 | 1339 | " mark from last mark up the cursor as reviewed 1340 | nmap [u mx'cV`x[rmc 1341 | 1342 | " annotate the selected lines 1343 | vmap [a :py3 note_selection() 1344 | nmap [a :py3 note_current_line() 1345 | vmap [A :py3 note_selection(multi=True) 1346 | nmap [A :py3 note_current_line(multi=True) 1347 | 1348 | " add a finding for the selected lines 1349 | vmap [f :py3 note_selection(prefix="FINDING", prompt="finding") 1350 | nmap [f :py3 note_current_line(prefix="FINDING", prompt="finding") 1351 | vmap [F :py3 note_selection(prefix="FINDING", prompt="finding", multi=True) 1352 | nmap [F :py3 note_current_line(prefix="FINDING", prompt="finding", multi=True) 1353 | 1354 | nmap [d :py3 delete_note_on_line() 1355 | nmap [e :py3 edit_note_on_line() 1356 | 1357 | " add a todo for the selected lines 1358 | vmap [t :py3 note_selection(prefix="TODO", prompt="todo") 1359 | nmap [t :py3 note_current_line(prefix="TODO", prompt="todo") 1360 | vmap [T :py3 note_selection(prefix="TODO", prompt="todo", multi=True) 1361 | nmap [T :py3 note_current_line(prefix="TODO", prompt="todo", multi=True) 1362 | 1363 | " toggle auditing (q like record) 1364 | " NOTE - not really recommended, is more of an experiment 1365 | " map [q :py3 toggle_audit() 1366 | 1367 | nmap [H :py3 toggle_annotations() 1368 | 1369 | " show the current report 1370 | nmap [R :py3 toggle_report() 1371 | 1372 | " show all notes containing the current line 1373 | " this should not be needed, as the current line's notes are automatically 1374 | " displayed 1375 | map [? :py3 show_current_notes(status_line_notes_override=True) 1376 | 1377 | " show a recent history 1378 | map [h :py3 toggle_history() 1379 | 1380 | " open the filepath under the cursor in a new tab 1381 | map [o gF:setlocal ro:setlocal nomodifiable 1382 | 1383 | " jump to the previous note 1384 | nmap [n :py3 jump_to_note() 1385 | nmap [N :py3 jump_to_note(direction=-1) 1386 | 1387 | command! -nargs=0 PctReport py3 report() 1388 | command! -nargs=0 PctNotes py3 notes() 1389 | command! -nargs=0 PctAudit py3 toggle_audit() 1390 | command! -nargs=0 PctInit py3 init_db(True) 1391 | 1392 | " always show the status of files 1393 | set laststatus=2 1394 | 1395 | function! DefineHighlights() 1396 | highlight hl_finding ctermfg=red ctermbg=black 1397 | highlight hl_annotated_line cterm=bold ctermbg=black 1398 | highlight hl_todo ctermfg=yellow ctermbg=black 1399 | highlight hl_note ctermfg=green ctermbg=black 1400 | highlight hl_reviewed ctermfg=blue ctermbg=black 1401 | 1402 | sign define sign_reviewed text=RR texthl=hl_reviewed 1403 | sign define sign_finding text=!! texthl=hl_finding linehl=hl_annotated_line 1404 | sign define sign_todo text=?? texthl=hl_todo linehl=hl_annotated_line 1405 | sign define sign_note text=>> texthl=hl_note linehl=hl_annotated_line 1406 | 1407 | highlight hl_audit_100_complete ctermfg=green 1408 | highlight hl_audit_good ctermfg=green 1409 | highlight hl_audit_in_progress ctermfg=yellow 1410 | highlight hl_audit_not_much ctermfg=red 1411 | highlight hl_audit_out_of_scope ctermfg=blue 1412 | 1413 | sign define sign_audit_100_complete text=✓ linehl=hl_audit_100_complete texthl=hl_note 1414 | sign define sign_audit_good text=++ linehl=hl_audit_good texthl=hl_note 1415 | sign define sign_audit_in_progress text=+ linehl=hl_audit_in_progress texthl=hl_audit_in_progress 1416 | sign define sign_audit_not_much text=. linehl=hl_audit_not_much texthl=hl_audit_not_much 1417 | sign define sign_audit_out_of_scope text=X linehl=hl_audit_out_of_scope texthl=hl_audit_out_of_scope 1418 | endfunction 1419 | call DefineHighlights() 1420 | 1421 | function! MaybeSaveNote() 1422 | if exists("b:new_note") 1423 | py3 save_note_from_buffer() 1424 | endif 1425 | endfunction 1426 | 1427 | let g:loaded_pct = 1 1428 | --------------------------------------------------------------------------------