Dave Jarvis' Repositories

git clone https://repo.autonoma.ca/repo/keenwrite.git
M build.gradle
1
version = '1.1.0'
2
31
apply plugin: 'java'
42
apply plugin: 'java-library-distribution'
53
apply plugin: 'application'
6
7
sourceCompatibility = JavaVersion.VERSION_1_8
8
9
applicationName = 'scrivenvar'
10
11
mainClassName = 'com.scrivenvar.Main'
124
135
repositories {
...
4032
  compile files('libs/renjin-script-engine-0.8.2309-jar-with-dependencies.jar')
4133
}
34
35
version = '1.1.1'
36
applicationName = 'scrivenvar'
37
mainClassName = 'com.scrivenvar.Main'
38
sourceCompatibility = JavaVersion.VERSION_1_8
4239
4340
jar {
M src/main/java/com/scrivenvar/FileEditorTab.java
187187
  public void searchNext( final String needle ) {
188188
    final String haystack = getEditorText();
189
    final int index = haystack.indexOf( needle, getCaretPosition() );
189
    int index = haystack.indexOf( needle, getCaretPosition() );
190
191
    // Wrap around.
192
    if( index == -1 ) {
193
      index = haystack.indexOf( needle, 0 );
194
    }
190195
191196
    if( index >= 0 ) {
M src/main/java/com/scrivenvar/MainWindow.java
105105
  private StatusBar statusBar;
106106
  private Text lineNumberText;
107
108
  private DefinitionSource definitionSource;
109
  private DefinitionPane definitionPane;
110
  private FileEditorTabPane fileEditorPane;
111
  private HTMLPreviewPane previewPane;
112
113
  /**
114
   * Prevent re-instantiation processing classes.
115
   */
116
  private Map<FileEditorTab, Processor<String>> processors;
117
118
  public MainWindow() {
119
    initLayout();
120
    initSnitch();
121
    initDefinitionListener();
122
    initTabAddedListener();
123
    initTabChangedListener();
124
    initPreferences();
125
  }
126
127
  /**
128
   * Listen for file editor tab pane to receive an open definition source event.
129
   */
130
  private void initDefinitionListener() {
131
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
132
      (ObservableValue<? extends Path> definitionFile,
133
        final Path oldPath, final Path newPath) -> {
134
        openDefinition( newPath );
135
136
        // Indirectly refresh the resolved map.
137
        setProcessors( null );
138
139
        updateDefinitionPane();
140
141
        try {
142
          getSnitch().ignore( oldPath );
143
          getSnitch().listen( newPath );
144
        } catch( final IOException ex ) {
145
          error( ex );
146
        }
147
148
        // Will create new processors and therefore a new resolved map.
149
        refreshSelectedTab( getActiveFileEditor() );
150
      }
151
    );
152
  }
153
154
  /**
155
   * When tabs are added, hook the various change listeners onto the new tab so
156
   * that the preview pane refreshes as necessary.
157
   */
158
  private void initTabAddedListener() {
159
    final FileEditorTabPane editorPane = getFileEditorPane();
160
161
    // Make sure the text processor kicks off when new files are opened.
162
    final ObservableList<Tab> tabs = editorPane.getTabs();
163
164
    // Update the preview pane on tab changes.
165
    tabs.addListener(
166
      (final Change<? extends Tab> change) -> {
167
        while( change.next() ) {
168
          if( change.wasAdded() ) {
169
            // Multiple tabs can be added simultaneously.
170
            for( final Tab newTab : change.getAddedSubList() ) {
171
              final FileEditorTab tab = (FileEditorTab)newTab;
172
173
              initTextChangeListener( tab );
174
              initCaretParagraphListener( tab );
175
              initVariableNameInjector( tab );
176
//              initSyntaxListener( tab );
177
            }
178
          }
179
        }
180
      }
181
    );
182
  }
183
184
  /**
185
   * Reloads the preferences from the previous load.
186
   */
187
  private void initPreferences() {
188
    restoreDefinitionSource();
189
    getFileEditorPane().restorePreferences();
190
    updateDefinitionPane();
191
  }
192
193
  /**
194
   * Listen for new tab selection events.
195
   */
196
  private void initTabChangedListener() {
197
    final FileEditorTabPane editorPane = getFileEditorPane();
198
199
    // Update the preview pane changing tabs.
200
    editorPane.addTabSelectionListener(
201
      (ObservableValue<? extends Tab> tabPane,
202
        final Tab oldTab, final Tab newTab) -> {
203
204
        // If there was no old tab, then this is a first time load, which
205
        // can be ignored.
206
        if( oldTab != null ) {
207
          if( newTab == null ) {
208
            closeRemainingTab();
209
          }
210
          else {
211
            // Update the preview with the edited text.
212
            refreshSelectedTab( (FileEditorTab)newTab );
213
          }
214
        }
215
      }
216
    );
217
  }
218
219
  private void initTextChangeListener( final FileEditorTab tab ) {
220
    tab.addTextChangeListener(
221
      (ObservableValue<? extends String> editor,
222
        final String oldValue, final String newValue) -> {
223
        refreshSelectedTab( tab );
224
      }
225
    );
226
  }
227
228
  private void initCaretParagraphListener( final FileEditorTab tab ) {
229
    tab.addCaretParagraphListener(
230
      (ObservableValue<? extends Integer> editor,
231
        final Integer oldValue, final Integer newValue) -> {
232
        refreshSelectedTab( tab );
233
      }
234
    );
235
  }
236
237
  private void initVariableNameInjector( final FileEditorTab tab ) {
238
    VariableNameInjector.listen( tab, getDefinitionPane() );
239
  }
240
241
  /**
242
   * Watch for changes to external files. In particular, this awaits
243
   * modifications to any XSL files associated with XML files being edited. When
244
   * an XSL file is modified (external to the application), the snitch's ears
245
   * perk up and the file is reloaded. This keeps the XSL transformation up to
246
   * date with what's on the file system.
247
   */
248
  private void initSnitch() {
249
    getSnitch().addObserver( this );
250
  }
251
252
  /**
253
   * Called whenever the preview pane becomes out of sync with the file editor
254
   * tab. This can be called when the text changes, the caret paragraph changes,
255
   * or the file tab changes.
256
   *
257
   * @param tab The file editor tab that has been changed in some fashion.
258
   */
259
  private void refreshSelectedTab( final FileEditorTab tab ) {
260
    if( tab.isFileOpen() ) {
261
      getPreviewPane().setPath( tab.getPath() );
262
263
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
264
      final Position p = tab.getCaretOffset();
265
      getLineNumberText().setText(
266
        get( STATUS_BAR_LINE,
267
          p.getMajor() + 1,
268
          p.getMinor() + 1,
269
          tab.getCaretPosition() + 1
270
        )
271
      );
272
273
      Processor<String> processor = getProcessors().get( tab );
274
275
      if( processor == null ) {
276
        processor = createProcessor( tab );
277
        getProcessors().put( tab, processor );
278
      }
279
280
      try {
281
        getNotifier().clear();
282
        processor.processChain( tab.getEditorText() );
283
      } catch( final Exception ex ) {
284
        error( ex );
285
      }
286
    }
287
  }
288
289
  /**
290
   * Used to find text in the active file editor window.
291
   */
292
  private void find() {
293
    final TextField input = new TextField();
294
295
    input.setOnKeyPressed( (KeyEvent event) -> {
296
      switch( event.getCode() ) {
297
        case F3:
298
        case ENTER:
299
          getActiveFileEditor().searchNext( input.getText() );
300
          break;
301
        case F:
302
          if( !event.isControlDown() ) {
303
            break;
304
          }
305
        case ESCAPE:
306
          getStatusBar().setGraphic( null );
307
          getActiveFileEditor().getEditorPane().requestFocus();
308
          break;
309
      }
310
    } );
311
312
    // Remove when the input field loses focus.
313
    input.focusedProperty().addListener(
314
      (
315
        final ObservableValue<? extends Boolean> focused,
316
        final Boolean oFocus,
317
        final Boolean nFocus) -> {
318
        if( !nFocus ) {
319
          getStatusBar().setGraphic( null );
320
        }
321
      }
322
    );
323
324
    getStatusBar().setGraphic( input );
325
326
    input.requestFocus();
327
  }
328
329
  public void findNext() {
330
    System.out.println( "find next" );
331
  }
332
333
  /**
334
   * Returns the variable map of interpolated definitions.
335
   *
336
   * @return A map to help dereference variables.
337
   */
338
  private Map<String, String> getResolvedMap() {
339
    return getDefinitionSource().getResolvedMap();
340
  }
341
342
  /**
343
   * Returns the root node for the hierarchical definition source.
344
   *
345
   * @return Data to display in the definition pane.
346
   */
347
  private TreeView<String> getTreeView() {
348
    try {
349
      return getDefinitionSource().asTreeView();
350
    } catch( Exception e ) {
351
      error( e );
352
    }
353
354
    // Slightly redundant as getDefinitionSource() might have returned an
355
    // empty definition source.
356
    return (new EmptyDefinitionSource()).asTreeView();
357
  }
358
359
  /**
360
   * Called when a definition source is opened.
361
   *
362
   * @param path Path to the definition source that was opened.
363
   */
364
  private void openDefinition( final Path path ) {
365
    try {
366
      final DefinitionSource ds = createDefinitionSource( path.toString() );
367
      setDefinitionSource( ds );
368
      storeDefinitionSource();
369
      updateDefinitionPane();
370
    } catch( final Exception e ) {
371
      error( e );
372
    }
373
  }
374
375
  private void updateDefinitionPane() {
376
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
377
  }
378
379
  private void restoreDefinitionSource() {
380
    final Preferences preferences = getPreferences();
381
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
382
383
    // If there's no definition source set, don't try to load it.
384
    if( source != null ) {
385
      setDefinitionSource( createDefinitionSource( source ) );
386
    }
387
  }
388
389
  private void storeDefinitionSource() {
390
    final Preferences preferences = getPreferences();
391
    final DefinitionSource ds = getDefinitionSource();
392
393
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
394
  }
395
396
  /**
397
   * Called when the last open tab is closed to clear the preview pane.
398
   */
399
  private void closeRemainingTab() {
400
    getPreviewPane().clear();
401
  }
402
403
  /**
404
   * Called when an exception occurs that warrants the user's attention.
405
   *
406
   * @param e The exception with a message that the user should know about.
407
   */
408
  private void error( final Exception e ) {
409
    getNotifier().notify( e );
410
  }
411
412
  //---- File actions -------------------------------------------------------
413
  /**
414
   * Called when an observable instance has changed. This is called by both the
415
   * snitch service and the notify service. The snitch service can be called for
416
   * different file types, including definition sources.
417
   *
418
   * @param observable The observed instance.
419
   * @param value The noteworthy item.
420
   */
421
  @Override
422
  public void update( final Observable observable, final Object value ) {
423
    if( value != null ) {
424
      if( observable instanceof Snitch && value instanceof Path ) {
425
        final Path path = (Path)value;
426
        final FileTypePredicate predicate
427
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
428
429
        // Reload definitions.
430
        if( predicate.test( path.toFile() ) ) {
431
          updateDefinitionSource( path );
432
        }
433
434
        updateSelectedTab();
435
      }
436
      else if( observable instanceof Notifier && value instanceof String ) {
437
        updateStatusBar( (String)value );
438
      }
439
    }
440
  }
441
442
  /**
443
   * Updates the status bar to show the given message.
444
   *
445
   * @param s The message to show in the status bar.
446
   */
447
  private void updateStatusBar( final String s ) {
448
    Platform.runLater(
449
      () -> {
450
        final int index = s.indexOf( '\n' );
451
        final String message = s.substring( 0, index > 0 ? index : s.length() );
452
453
        getStatusBar().setText( message );
454
      }
455
    );
456
  }
457
458
  /**
459
   * Called when a file has been modified.
460
   *
461
   * @param file Path to the modified file.
462
   */
463
  private void updateSelectedTab() {
464
    Platform.runLater(
465
      () -> {
466
        // Brute-force XSLT file reload by re-instantiating all processors.
467
        resetProcessors();
468
        refreshSelectedTab( getActiveFileEditor() );
469
      }
470
    );
471
  }
472
473
  /**
474
   * Reloads the definition source from the given path.
475
   *
476
   * @param path The path containing new definition information.
477
   */
478
  private void updateDefinitionSource( final Path path ) {
479
    Platform.runLater(
480
      () -> {
481
        openDefinition( path );
482
      }
483
    );
484
  }
485
486
  /**
487
   * After resetting the processors, they will refresh anew to be up-to-date
488
   * with the files (text and definition) currently loaded into the editor.
489
   */
490
  private void resetProcessors() {
491
    getProcessors().clear();
492
  }
493
494
  //---- File actions -------------------------------------------------------
495
  private void fileNew() {
496
    getFileEditorPane().newEditor();
497
  }
498
499
  private void fileOpen() {
500
    getFileEditorPane().openFileDialog();
501
  }
502
503
  private void fileClose() {
504
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
505
  }
506
507
  private void fileCloseAll() {
508
    getFileEditorPane().closeAllEditors();
509
  }
510
511
  private void fileSave() {
512
    getFileEditorPane().saveEditor( getActiveFileEditor() );
513
  }
514
515
  private void fileSaveAll() {
516
    getFileEditorPane().saveAllEditors();
517
  }
518
519
  private void fileExit() {
520
    final Window window = getWindow();
521
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
522
  }
523
524
  //---- Help actions -------------------------------------------------------
525
  private void helpAbout() {
526
    Alert alert = new Alert( AlertType.INFORMATION );
527
    alert.setTitle( get( "Dialog.about.title" ) );
528
    alert.setHeaderText( get( "Dialog.about.header" ) );
529
    alert.setContentText( get( "Dialog.about.content" ) );
530
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
531
    alert.initOwner( getWindow() );
532
533
    alert.showAndWait();
534
  }
535
536
  //---- Convenience accessors ----------------------------------------------
537
  private float getFloat( final String key, final float defaultValue ) {
538
    return getPreferences().getFloat( key, defaultValue );
539
  }
540
541
  private Preferences getPreferences() {
542
    return getOptions().getState();
543
  }
544
545
  protected Scene getScene() {
546
    if( this.scene == null ) {
547
      this.scene = createScene();
548
    }
549
550
    return this.scene;
551
  }
552
553
  public Window getWindow() {
554
    return getScene().getWindow();
555
  }
556
557
  private MarkdownEditorPane getActiveEditor() {
558
    final EditorPane pane = getActiveFileEditor().getEditorPane();
559
560
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
561
  }
562
563
  private FileEditorTab getActiveFileEditor() {
564
    return getFileEditorPane().getActiveFileEditor();
565
  }
566
567
  //---- Member accessors ---------------------------------------------------
568
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
569
    this.processors = map;
570
  }
571
572
  private Map<FileEditorTab, Processor<String>> getProcessors() {
573
    if( this.processors == null ) {
574
      setProcessors( new HashMap<>() );
575
    }
576
577
    return this.processors;
578
  }
579
580
  private FileEditorTabPane getFileEditorPane() {
581
    if( this.fileEditorPane == null ) {
582
      this.fileEditorPane = createFileEditorPane();
583
    }
584
585
    return this.fileEditorPane;
586
  }
587
588
  private HTMLPreviewPane getPreviewPane() {
589
    if( this.previewPane == null ) {
590
      this.previewPane = createPreviewPane();
591
    }
592
593
    return this.previewPane;
594
  }
595
596
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
597
    this.definitionSource = definitionSource;
598
  }
599
600
  private DefinitionSource getDefinitionSource() {
601
    if( this.definitionSource == null ) {
602
      this.definitionSource = new EmptyDefinitionSource();
603
    }
604
605
    return this.definitionSource;
606
  }
607
608
  private DefinitionPane getDefinitionPane() {
609
    if( this.definitionPane == null ) {
610
      this.definitionPane = createDefinitionPane();
611
    }
612
613
    return this.definitionPane;
614
  }
615
616
  private Options getOptions() {
617
    return this.options;
618
  }
619
620
  private Snitch getSnitch() {
621
    return this.snitch;
622
  }
623
624
  private Notifier getNotifier() {
625
    return this.notifier;
626
  }
627
628
  public void setMenuBar( final MenuBar menuBar ) {
629
    this.menuBar = menuBar;
630
  }
631
632
  public MenuBar getMenuBar() {
633
    return this.menuBar;
634
  }
635
636
  private Text getLineNumberText() {
637
    if( this.lineNumberText == null ) {
638
      this.lineNumberText = createLineNumberText();
639
    }
640
641
    return this.lineNumberText;
642
  }
643
644
  private synchronized StatusBar getStatusBar() {
645
    if( this.statusBar == null ) {
646
      this.statusBar = createStatusBar();
647
    }
648
649
    return this.statusBar;
107
  private TextField findTextField;
108
109
  private DefinitionSource definitionSource;
110
  private DefinitionPane definitionPane;
111
  private FileEditorTabPane fileEditorPane;
112
  private HTMLPreviewPane previewPane;
113
114
  /**
115
   * Prevent re-instantiation processing classes.
116
   */
117
  private Map<FileEditorTab, Processor<String>> processors;
118
119
  public MainWindow() {
120
    initLayout();
121
    initFindInput();
122
    initSnitch();
123
    initDefinitionListener();
124
    initTabAddedListener();
125
    initTabChangedListener();
126
    initPreferences();
127
  }
128
129
  /**
130
   * Watch for changes to external files. In particular, this awaits
131
   * modifications to any XSL files associated with XML files being edited. When
132
   * an XSL file is modified (external to the application), the snitch's ears
133
   * perk up and the file is reloaded. This keeps the XSL transformation up to
134
   * date with what's on the file system.
135
   */
136
  private void initSnitch() {
137
    getSnitch().addObserver( this );
138
  }
139
140
  /**
141
   * Initialize the find input text field to listen on F3, ENTER, and ESCAPE key
142
   * presses.
143
   */
144
  private void initFindInput() {
145
    final TextField input = getFindTextField();
146
147
    input.setOnKeyPressed( (KeyEvent event) -> {
148
      switch( event.getCode() ) {
149
        case F3:
150
        case ENTER:
151
          findNext();
152
          break;
153
        case F:
154
          if( !event.isControlDown() ) {
155
            break;
156
          }
157
        case ESCAPE:
158
          getStatusBar().setGraphic( null );
159
          getActiveFileEditor().getEditorPane().requestFocus();
160
          break;
161
      }
162
    } );
163
164
    // Remove when the input field loses focus.
165
    input.focusedProperty().addListener(
166
      (
167
        final ObservableValue<? extends Boolean> focused,
168
        final Boolean oFocus,
169
        final Boolean nFocus) -> {
170
        if( !nFocus ) {
171
          getStatusBar().setGraphic( null );
172
        }
173
      }
174
    );
175
  }
176
177
  /**
178
   * Listen for file editor tab pane to receive an open definition source event.
179
   */
180
  private void initDefinitionListener() {
181
    getFileEditorPane().onOpenDefinitionFileProperty().addListener(
182
      (ObservableValue<? extends Path> definitionFile,
183
        final Path oldPath, final Path newPath) -> {
184
        openDefinition( newPath );
185
186
        // Indirectly refresh the resolved map.
187
        setProcessors( null );
188
189
        updateDefinitionPane();
190
191
        try {
192
          getSnitch().ignore( oldPath );
193
          getSnitch().listen( newPath );
194
        } catch( final IOException ex ) {
195
          error( ex );
196
        }
197
198
        // Will create new processors and therefore a new resolved map.
199
        refreshSelectedTab( getActiveFileEditor() );
200
      }
201
    );
202
  }
203
204
  /**
205
   * When tabs are added, hook the various change listeners onto the new tab so
206
   * that the preview pane refreshes as necessary.
207
   */
208
  private void initTabAddedListener() {
209
    final FileEditorTabPane editorPane = getFileEditorPane();
210
211
    // Make sure the text processor kicks off when new files are opened.
212
    final ObservableList<Tab> tabs = editorPane.getTabs();
213
214
    // Update the preview pane on tab changes.
215
    tabs.addListener(
216
      (final Change<? extends Tab> change) -> {
217
        while( change.next() ) {
218
          if( change.wasAdded() ) {
219
            // Multiple tabs can be added simultaneously.
220
            for( final Tab newTab : change.getAddedSubList() ) {
221
              final FileEditorTab tab = (FileEditorTab)newTab;
222
223
              initTextChangeListener( tab );
224
              initCaretParagraphListener( tab );
225
              initVariableNameInjector( tab );
226
//              initSyntaxListener( tab );
227
            }
228
          }
229
        }
230
      }
231
    );
232
  }
233
234
  /**
235
   * Reloads the preferences from the previous load.
236
   */
237
  private void initPreferences() {
238
    restoreDefinitionSource();
239
    getFileEditorPane().restorePreferences();
240
    updateDefinitionPane();
241
  }
242
243
  /**
244
   * Listen for new tab selection events.
245
   */
246
  private void initTabChangedListener() {
247
    final FileEditorTabPane editorPane = getFileEditorPane();
248
249
    // Update the preview pane changing tabs.
250
    editorPane.addTabSelectionListener(
251
      (ObservableValue<? extends Tab> tabPane,
252
        final Tab oldTab, final Tab newTab) -> {
253
254
        // If there was no old tab, then this is a first time load, which
255
        // can be ignored.
256
        if( oldTab != null ) {
257
          if( newTab == null ) {
258
            closeRemainingTab();
259
          }
260
          else {
261
            // Update the preview with the edited text.
262
            refreshSelectedTab( (FileEditorTab)newTab );
263
          }
264
        }
265
      }
266
    );
267
  }
268
269
  private void initTextChangeListener( final FileEditorTab tab ) {
270
    tab.addTextChangeListener(
271
      (ObservableValue<? extends String> editor,
272
        final String oldValue, final String newValue) -> {
273
        refreshSelectedTab( tab );
274
      }
275
    );
276
  }
277
278
  private void initCaretParagraphListener( final FileEditorTab tab ) {
279
    tab.addCaretParagraphListener(
280
      (ObservableValue<? extends Integer> editor,
281
        final Integer oldValue, final Integer newValue) -> {
282
        refreshSelectedTab( tab );
283
      }
284
    );
285
  }
286
287
  private void initVariableNameInjector( final FileEditorTab tab ) {
288
    VariableNameInjector.listen( tab, getDefinitionPane() );
289
  }
290
291
  /**
292
   * Called whenever the preview pane becomes out of sync with the file editor
293
   * tab. This can be called when the text changes, the caret paragraph changes,
294
   * or the file tab changes.
295
   *
296
   * @param tab The file editor tab that has been changed in some fashion.
297
   */
298
  private void refreshSelectedTab( final FileEditorTab tab ) {
299
    if( tab.isFileOpen() ) {
300
      getPreviewPane().setPath( tab.getPath() );
301
302
      // TODO: https://github.com/DaveJarvis/scrivenvar/issues/29
303
      final Position p = tab.getCaretOffset();
304
      getLineNumberText().setText(
305
        get( STATUS_BAR_LINE,
306
          p.getMajor() + 1,
307
          p.getMinor() + 1,
308
          tab.getCaretPosition() + 1
309
        )
310
      );
311
312
      Processor<String> processor = getProcessors().get( tab );
313
314
      if( processor == null ) {
315
        processor = createProcessor( tab );
316
        getProcessors().put( tab, processor );
317
      }
318
319
      try {
320
        getNotifier().clear();
321
        processor.processChain( tab.getEditorText() );
322
      } catch( final Exception ex ) {
323
        error( ex );
324
      }
325
    }
326
  }
327
328
  /**
329
   * Used to find text in the active file editor window.
330
   */
331
  private void find() {
332
    final TextField input = getFindTextField();
333
    getStatusBar().setGraphic( input );
334
    input.requestFocus();
335
  }
336
337
  public void findNext() {
338
    getActiveFileEditor().searchNext( getFindTextField().getText() );
339
  }
340
341
  /**
342
   * Returns the variable map of interpolated definitions.
343
   *
344
   * @return A map to help dereference variables.
345
   */
346
  private Map<String, String> getResolvedMap() {
347
    return getDefinitionSource().getResolvedMap();
348
  }
349
350
  /**
351
   * Returns the root node for the hierarchical definition source.
352
   *
353
   * @return Data to display in the definition pane.
354
   */
355
  private TreeView<String> getTreeView() {
356
    try {
357
      return getDefinitionSource().asTreeView();
358
    } catch( Exception e ) {
359
      error( e );
360
    }
361
362
    // Slightly redundant as getDefinitionSource() might have returned an
363
    // empty definition source.
364
    return (new EmptyDefinitionSource()).asTreeView();
365
  }
366
367
  /**
368
   * Called when a definition source is opened.
369
   *
370
   * @param path Path to the definition source that was opened.
371
   */
372
  private void openDefinition( final Path path ) {
373
    try {
374
      final DefinitionSource ds = createDefinitionSource( path.toString() );
375
      setDefinitionSource( ds );
376
      storeDefinitionSource();
377
      updateDefinitionPane();
378
    } catch( final Exception e ) {
379
      error( e );
380
    }
381
  }
382
383
  private void updateDefinitionPane() {
384
    getDefinitionPane().setRoot( getDefinitionSource().asTreeView() );
385
  }
386
387
  private void restoreDefinitionSource() {
388
    final Preferences preferences = getPreferences();
389
    final String source = preferences.get( PREFS_DEFINITION_SOURCE, null );
390
391
    // If there's no definition source set, don't try to load it.
392
    if( source != null ) {
393
      setDefinitionSource( createDefinitionSource( source ) );
394
    }
395
  }
396
397
  private void storeDefinitionSource() {
398
    final Preferences preferences = getPreferences();
399
    final DefinitionSource ds = getDefinitionSource();
400
401
    preferences.put( PREFS_DEFINITION_SOURCE, ds.toString() );
402
  }
403
404
  /**
405
   * Called when the last open tab is closed to clear the preview pane.
406
   */
407
  private void closeRemainingTab() {
408
    getPreviewPane().clear();
409
  }
410
411
  /**
412
   * Called when an exception occurs that warrants the user's attention.
413
   *
414
   * @param e The exception with a message that the user should know about.
415
   */
416
  private void error( final Exception e ) {
417
    getNotifier().notify( e );
418
  }
419
420
  //---- File actions -------------------------------------------------------
421
  /**
422
   * Called when an observable instance has changed. This is called by both the
423
   * snitch service and the notify service. The snitch service can be called for
424
   * different file types, including definition sources.
425
   *
426
   * @param observable The observed instance.
427
   * @param value The noteworthy item.
428
   */
429
  @Override
430
  public void update( final Observable observable, final Object value ) {
431
    if( value != null ) {
432
      if( observable instanceof Snitch && value instanceof Path ) {
433
        final Path path = (Path)value;
434
        final FileTypePredicate predicate
435
          = new FileTypePredicate( GLOB_DEFINITION_EXTENSIONS );
436
437
        // Reload definitions.
438
        if( predicate.test( path.toFile() ) ) {
439
          updateDefinitionSource( path );
440
        }
441
442
        updateSelectedTab();
443
      }
444
      else if( observable instanceof Notifier && value instanceof String ) {
445
        updateStatusBar( (String)value );
446
      }
447
    }
448
  }
449
450
  /**
451
   * Updates the status bar to show the given message.
452
   *
453
   * @param s The message to show in the status bar.
454
   */
455
  private void updateStatusBar( final String s ) {
456
    Platform.runLater(
457
      () -> {
458
        final int index = s.indexOf( '\n' );
459
        final String message = s.substring( 0, index > 0 ? index : s.length() );
460
461
        getStatusBar().setText( message );
462
      }
463
    );
464
  }
465
466
  /**
467
   * Called when a file has been modified.
468
   *
469
   * @param file Path to the modified file.
470
   */
471
  private void updateSelectedTab() {
472
    Platform.runLater(
473
      () -> {
474
        // Brute-force XSLT file reload by re-instantiating all processors.
475
        resetProcessors();
476
        refreshSelectedTab( getActiveFileEditor() );
477
      }
478
    );
479
  }
480
481
  /**
482
   * Reloads the definition source from the given path.
483
   *
484
   * @param path The path containing new definition information.
485
   */
486
  private void updateDefinitionSource( final Path path ) {
487
    Platform.runLater(
488
      () -> {
489
        openDefinition( path );
490
      }
491
    );
492
  }
493
494
  /**
495
   * After resetting the processors, they will refresh anew to be up-to-date
496
   * with the files (text and definition) currently loaded into the editor.
497
   */
498
  private void resetProcessors() {
499
    getProcessors().clear();
500
  }
501
502
  //---- File actions -------------------------------------------------------
503
  private void fileNew() {
504
    getFileEditorPane().newEditor();
505
  }
506
507
  private void fileOpen() {
508
    getFileEditorPane().openFileDialog();
509
  }
510
511
  private void fileClose() {
512
    getFileEditorPane().closeEditor( getActiveFileEditor(), true );
513
  }
514
515
  private void fileCloseAll() {
516
    getFileEditorPane().closeAllEditors();
517
  }
518
519
  private void fileSave() {
520
    getFileEditorPane().saveEditor( getActiveFileEditor() );
521
  }
522
523
  private void fileSaveAll() {
524
    getFileEditorPane().saveAllEditors();
525
  }
526
527
  private void fileExit() {
528
    final Window window = getWindow();
529
    fireEvent( window, new WindowEvent( window, WINDOW_CLOSE_REQUEST ) );
530
  }
531
532
  //---- Help actions -------------------------------------------------------
533
  private void helpAbout() {
534
    Alert alert = new Alert( AlertType.INFORMATION );
535
    alert.setTitle( get( "Dialog.about.title" ) );
536
    alert.setHeaderText( get( "Dialog.about.header" ) );
537
    alert.setContentText( get( "Dialog.about.content" ) );
538
    alert.setGraphic( new ImageView( new Image( FILE_LOGO_32 ) ) );
539
    alert.initOwner( getWindow() );
540
541
    alert.showAndWait();
542
  }
543
544
  //---- Convenience accessors ----------------------------------------------
545
  private float getFloat( final String key, final float defaultValue ) {
546
    return getPreferences().getFloat( key, defaultValue );
547
  }
548
549
  private Preferences getPreferences() {
550
    return getOptions().getState();
551
  }
552
553
  private TextField createFindTextField() {
554
    return new TextField();
555
  }
556
557
  protected Scene getScene() {
558
    if( this.scene == null ) {
559
      this.scene = createScene();
560
    }
561
562
    return this.scene;
563
  }
564
565
  public Window getWindow() {
566
    return getScene().getWindow();
567
  }
568
569
  private MarkdownEditorPane getActiveEditor() {
570
    final EditorPane pane = getActiveFileEditor().getEditorPane();
571
572
    return pane instanceof MarkdownEditorPane ? (MarkdownEditorPane)pane : null;
573
  }
574
575
  private FileEditorTab getActiveFileEditor() {
576
    return getFileEditorPane().getActiveFileEditor();
577
  }
578
579
  //---- Member accessors ---------------------------------------------------
580
  private void setProcessors( final Map<FileEditorTab, Processor<String>> map ) {
581
    this.processors = map;
582
  }
583
584
  private Map<FileEditorTab, Processor<String>> getProcessors() {
585
    if( this.processors == null ) {
586
      setProcessors( new HashMap<>() );
587
    }
588
589
    return this.processors;
590
  }
591
592
  private FileEditorTabPane getFileEditorPane() {
593
    if( this.fileEditorPane == null ) {
594
      this.fileEditorPane = createFileEditorPane();
595
    }
596
597
    return this.fileEditorPane;
598
  }
599
600
  private HTMLPreviewPane getPreviewPane() {
601
    if( this.previewPane == null ) {
602
      this.previewPane = createPreviewPane();
603
    }
604
605
    return this.previewPane;
606
  }
607
608
  private void setDefinitionSource( final DefinitionSource definitionSource ) {
609
    this.definitionSource = definitionSource;
610
  }
611
612
  private DefinitionSource getDefinitionSource() {
613
    if( this.definitionSource == null ) {
614
      this.definitionSource = new EmptyDefinitionSource();
615
    }
616
617
    return this.definitionSource;
618
  }
619
620
  private DefinitionPane getDefinitionPane() {
621
    if( this.definitionPane == null ) {
622
      this.definitionPane = createDefinitionPane();
623
    }
624
625
    return this.definitionPane;
626
  }
627
628
  private Options getOptions() {
629
    return this.options;
630
  }
631
632
  private Snitch getSnitch() {
633
    return this.snitch;
634
  }
635
636
  private Notifier getNotifier() {
637
    return this.notifier;
638
  }
639
640
  public void setMenuBar( final MenuBar menuBar ) {
641
    this.menuBar = menuBar;
642
  }
643
644
  public MenuBar getMenuBar() {
645
    return this.menuBar;
646
  }
647
648
  private Text getLineNumberText() {
649
    if( this.lineNumberText == null ) {
650
      this.lineNumberText = createLineNumberText();
651
    }
652
653
    return this.lineNumberText;
654
  }
655
656
  private synchronized StatusBar getStatusBar() {
657
    if( this.statusBar == null ) {
658
      this.statusBar = createStatusBar();
659
    }
660
661
    return this.statusBar;
662
  }
663
664
  private TextField getFindTextField() {
665
    if( this.findTextField == null ) {
666
      this.findTextField = createFindTextField();
667
    }
668
669
    return this.findTextField;
650670
  }
651671
M src/main/java/com/scrivenvar/definition/yaml/YamlParser.java
4141
import java.io.InputStream;
4242
import java.io.Writer;
43
import java.security.InvalidParameterException;
44
import java.text.MessageFormat;
45
import java.util.HashMap;
46
import java.util.Map;
47
import java.util.Map.Entry;
48
import java.util.regex.Matcher;
49
import java.util.regex.Pattern;
50
import org.yaml.snakeyaml.DumperOptions;
51
52
/**
53
 * <p>
54
 * This program loads a YAML document into memory, scans for variable
55
 * declarations, then substitutes any self-referential values back into the
56
 * document. Its output is the given YAML document without any variables.
57
 * Variables in the YAML document are denoted using a bracketed dollar symbol
58
 * syntax. For example: $field.name$. Some nomenclature to keep from going
59
 * squirrely, consider:
60
 * </p>
61
 *
62
 * <pre>
63
 *   root:
64
 *     node:
65
 *       name: $field.name$
66
 *   field:
67
 *     name: Alan Turing
68
 * </pre>
69
 *
70
 * The various components of the given YAML are called:
71
 *
72
 * <ul>
73
 * <li><code>$field.name$</code> - delimited reference</li>
74
 * <li><code>field.name</code> - reference</li>
75
 * <li><code>name</code> - YAML field</li>
76
 * <li><code>Alan Turing</code> - (dereferenced) field value</li>
77
 * </ul>
78
 *
79
 * @author White Magic Software, Ltd.
80
 */
81
public class YamlParser {
82
83
  /**
84
   * Separates YAML variable nodes (e.g., the dots in
85
   * <code>$root.node.var$</code>).
86
   */
87
  public static final String SEPARATOR = ".";
88
  public static final char SEPARATOR_CHAR = SEPARATOR.charAt( 0 );
89
  
90
  private final static int GROUP_DELIMITED = 1;
91
  private final static int GROUP_REFERENCE = 2;
92
  
93
  private final static VariableDecorator VARIABLE_DECORATOR
94
    = new YamlVariableDecorator();
95
96
  /**
97
   * Compiled version of DEFAULT_REGEX.
98
   */
99
  private final static Pattern REGEX_PATTERN
100
    = Pattern.compile( YamlVariableDecorator.REGEX );
101
102
  /**
103
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
104
   */
105
  private final static char SEPARATOR_YAML = '/';
106
107
  /**
108
   * Start of the Universe (the YAML document node that contains all others).
109
   */
110
  private JsonNode documentRoot;
111
112
  /**
113
   * Map of references to dereferenced field values.
114
   */
115
  private Map<String, String> references;
116
  
117
  public YamlParser( final InputStream in ) throws IOException {
118
    process( in );
119
  }
120
121
  /**
122
   * Returns the given string with all the delimited references swapped with
123
   * their recursively resolved values.
124
   *
125
   * @param text The text to parse with zero or more delimited references to
126
   * replace.
127
   *
128
   * @return The substituted value.
129
   */
130
  public String substitute( String text ) {
131
    final Matcher matcher = patternMatch( text );
132
    final Map<String, String> map = getReferences();
133
    
134
    while( matcher.find() ) {
135
      final String key = matcher.group( GROUP_DELIMITED );
136
      final String value = map.get( key );
137
      
138
      if( value == null ) {
139
        missing( text );
140
      } else {
141
        text = text.replace( key, value );
142
      }
143
    }
144
    
145
    return text;
146
  }
147
148
  /**
149
   * Returns all the strings with their values resolved in a flat hierarchy.
150
   * This copies all the keys and resolved values into a new map.
151
   *
152
   * @return The new map created with all values having been resolved,
153
   * recursively.
154
   */
155
  public Map<String, String> createResolvedMap() {
156
    final Map<String, String> map = new HashMap<>( 1024 );
157
    
158
    resolve( getDocumentRoot(), "", map );
159
    
160
    return map;
161
  }
162
163
  /**
164
   * Iterate over a given root node (at any level of the tree) and adapt each
165
   * leaf node.
166
   *
167
   * @param rootNode A JSON node (YAML node) to adapt.
168
   * @param map Container that associates definitions with values.
169
   */
170
  private void resolve(
171
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
172
    
173
    if( rootNode != null ) {
174
      rootNode.fields().forEachRemaining(
175
        (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
176
      );
177
    }
178
  }
179
180
  /**
181
   * Recursively adapt each rootNode to a corresponding rootItem.
182
   *
183
   * @param rootNode The node to adapt.
184
   */
185
  private void resolve(
186
    final Entry<String, JsonNode> rootNode,
187
    final String path,
188
    final Map<String, String> map ) {
189
    
190
    final JsonNode leafNode = rootNode.getValue();
191
    final String key = rootNode.getKey();
192
    
193
    if( leafNode.isValueNode() ) {
194
      final String value = rootNode.getValue().asText();
195
      
196
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
197
    }
198
    
199
    if( leafNode.isObject() ) {
200
      resolve( leafNode, path + key + SEPARATOR, map );
201
    }
202
  }
203
204
  /**
205
   * Reads the first document from the given stream of YAML data and returns a
206
   * corresponding object that represents the YAML hierarchy. The calling class
207
   * is responsible for closing the stream. Calling classes should use
208
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
209
   *
210
   * @param in The input stream containing YAML content.
211
   *
212
   * @return An object hierarchy to represent the content.
213
   *
214
   * @throws IOException Could not read the stream.
215
   */
216
  private JsonNode process( final InputStream in ) throws IOException {
217
    
218
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
219
    setDocumentRoot( root );
220
    process( root );
221
    return getDocumentRoot();
222
  }
223
224
  /**
225
   * Iterate over a given root node (at any level of the tree) and process each
226
   * leaf node.
227
   *
228
   * @param root A node to process.
229
   */
230
  private void process( final JsonNode root ) {
231
    root.fields().forEachRemaining( this::process );
232
  }
233
234
  /**
235
   * Process the given field, which is a named node. This is where the
236
   * application does the up-front work of mapping references to their fully
237
   * recursively dereferenced values.
238
   *
239
   * @param field The named node.
240
   */
241
  private void process( final Entry<String, JsonNode> field ) {
242
    final JsonNode node = field.getValue();
243
    
244
    if( node.isObject() ) {
245
      process( node );
246
    } else {
247
      final JsonNode fieldValue = field.getValue();
248
249
      // Only basic data types can be parsed into variable values. For
250
      // node structures, YAML has a built-in mechanism.
251
      if( fieldValue.isValueNode() ) {
252
        try {
253
          resolve( fieldValue.asText() );
254
        } catch( StackOverflowError e ) {
255
          throw new IllegalArgumentException(
256
            "Unresolvable: " + node.textValue() + " = " + fieldValue );
257
        }
258
      }
259
    }
260
  }
261
262
  /**
263
   * Inserts the delimited references and field values into the cache. This will
264
   * overwrite existing references.
265
   *
266
   * @param fieldValue YAML field containing zero or more delimited references.
267
   * If it contains a delimited reference, the parameter is modified with the
268
   * dereferenced value before it is returned.
269
   *
270
   * @return fieldValue without delimited references.
271
   */
272
  private String resolve( String fieldValue ) {
273
    final Matcher matcher = patternMatch( fieldValue );
274
    
275
    while( matcher.find() ) {
276
      final String delimited = matcher.group( GROUP_DELIMITED );
277
      final String reference = matcher.group( GROUP_REFERENCE );
278
      final String dereference = resolve( lookup( reference ) );
279
      
280
      fieldValue = fieldValue.replace( delimited, dereference );
281
282
      // This will perform some superfluous calls by overwriting existing
283
      // items in the delimited reference map.
284
      put( delimited, dereference );
285
    }
286
    
287
    return fieldValue;
288
  }
289
290
  /**
291
   * Inserts a key/value pair into the references map. The map retains
292
   * references and dereferenced values found in the YAML. If the reference
293
   * already exists, this will overwrite with a new value.
294
   *
295
   * @param delimited The variable name.
296
   * @param dereferenced The resolved value.
297
   */
298
  private void put( String delimited, String dereferenced ) {
299
    if( dereferenced.isEmpty() ) {
300
      missing( delimited );
301
    } else {
302
      getReferences().put( delimited, dereferenced );
303
    }
304
  }
305
306
  /**
307
   * Writes the modified YAML document to standard output.
308
   */
309
  private void writeDocument() throws IOException {
310
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
311
  }
312
313
  /**
314
   * Called when a delimited reference is dereferenced to an empty string. This
315
   * should produce a warning for the user.
316
   *
317
   * @param delimited Delimited reference with no derived value.
318
   */
319
  private void missing( final String delimited ) {
320
    throw new InvalidParameterException(
321
      MessageFormat.format( "Missing value for '{0}'.", delimited ) );
322
  }
323
324
  /**
325
   * Returns a REGEX_PATTERN matcher for the given text.
326
   *
327
   * @param text The text that contains zero or more instances of a
328
   * REGEX_PATTERN that can be found using the regular expression.
329
   */
330
  private Matcher patternMatch( String text ) {
331
    return getPattern().matcher( text );
332
  }
333
334
  /**
335
   * Finds the YAML value for a reference.
336
   *
337
   * @param reference References a value in the YAML document.
338
   *
339
   * @return The dereferenced value.
340
   */
341
  private String lookup( final String reference ) {
342
    return getDocumentRoot().at( asPath( reference ) ).asText();
343
  }
344
345
  /**
346
   * Converts a reference (not delimited) to a path that can be used to find a
347
   * value that should exist inside the YAML document.
348
   *
349
   * @param reference The reference to convert to a YAML document path.
350
   *
351
   * @return The reference with a leading slash and its separator characters
352
   * converted to slashes.
353
   */
354
  private String asPath( final String reference ) {
355
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
356
  }
357
358
  /**
359
   * Sets the parent node for the entire YAML document tree.
360
   *
361
   * @param documentRoot The parent node.
362
   */
363
  private void setDocumentRoot( ObjectNode documentRoot ) {
364
    this.documentRoot = documentRoot;
365
  }
366
367
  /**
368
   * Returns the parent node for the entire YAML document tree.
369
   *
370
   * @return The parent node.
371
   */
372
  protected JsonNode getDocumentRoot() {
373
    return this.documentRoot;
374
  }
375
376
  /**
377
   * Returns the compiled regular expression REGEX_PATTERN used to match
378
   * delimited references.
379
   *
380
   * @return A compiled regex for use with the Matcher.
381
   */
382
  private Pattern getPattern() {
383
    return REGEX_PATTERN;
384
  }
385
386
  /**
387
   * Returns the list of references mapped to dereferenced values.
388
   *
389
   * @return
390
   */
391
  private Map<String, String> getReferences() {
392
    if( this.references == null ) {
393
      this.references = createReferences();
394
    }
395
    
396
    return this.references;
397
  }
398
399
  /**
400
   * Subclasses can override this method to insert their own map.
401
   *
402
   * @return An empty HashMap, never null.
403
   */
404
  protected Map<String, String> createReferences() {
405
    return new HashMap<>();
406
  }
407
  
408
  private final class ResolverYAMLFactory extends YAMLFactory {
409
    
410
    private static final long serialVersionUID = 1L;
411
    
412
    @Override
413
    protected YAMLGenerator _createGenerator(
414
      final Writer out, final IOContext ctxt ) throws IOException {
415
      
416
      return new ResolverYAMLGenerator(
417
        ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
418
        out, _version );
419
    }
420
  }
421
  
422
  private class ResolverYAMLGenerator extends YAMLGenerator {
423
    
424
    public ResolverYAMLGenerator(
425
      final IOContext ctxt,
426
      final int jsonFeatures,
427
      final int yamlFeatures,
428
      final ObjectCodec codec,
429
      final Writer out,
430
      final DumperOptions.Version version ) throws IOException {
431
      
432
      super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
433
    }
434
    
435
    @Override
436
    public void writeString( final String text )
437
      throws IOException, JsonGenerationException {
438
      super.writeString( substitute( text ) );
439
    }
440
  }
441
  
442
  private YAMLFactory getYAMLFactory() {
443
    return new ResolverYAMLFactory();
444
  }
445
  
446
  private ObjectMapper getObjectMapper() {
447
    return new ObjectMapper( getYAMLFactory() );
448
  }
449
450
  /**
451
   * Returns the character used to separate YAML paths within delimited
452
   * references. This will return only the first character of the command line
453
   * parameter, if the default is overridden.
454
   *
455
   * @return A period by default.
456
   */
457
  private char getDelimitedSeparator() {
458
    return SEPARATOR.charAt( 0 );
43
import java.text.MessageFormat;
44
import java.util.HashMap;
45
import java.util.Map;
46
import java.util.Map.Entry;
47
import java.util.regex.Matcher;
48
import java.util.regex.Pattern;
49
import org.yaml.snakeyaml.DumperOptions;
50
51
/**
52
 * <p>
53
 * This program loads a YAML document into memory, scans for variable
54
 * declarations, then substitutes any self-referential values back into the
55
 * document. Its output is the given YAML document without any variables.
56
 * Variables in the YAML document are denoted using a bracketed dollar symbol
57
 * syntax. For example: $field.name$. Some nomenclature to keep from going
58
 * squirrely, consider:
59
 * </p>
60
 *
61
 * <pre>
62
 *   root:
63
 *     node:
64
 *       name: $field.name$
65
 *   field:
66
 *     name: Alan Turing
67
 * </pre>
68
 *
69
 * The various components of the given YAML are called:
70
 *
71
 * <ul>
72
 * <li><code>$field.name$</code> - delimited reference</li>
73
 * <li><code>field.name</code> - reference</li>
74
 * <li><code>name</code> - YAML field</li>
75
 * <li><code>Alan Turing</code> - (dereferenced) field value</li>
76
 * </ul>
77
 *
78
 * @author White Magic Software, Ltd.
79
 */
80
public class YamlParser {
81
82
  /**
83
   * Separates YAML variable nodes (e.g., the dots in
84
   * <code>$root.node.var$</code>).
85
   */
86
  public static final String SEPARATOR = ".";
87
  public static final char SEPARATOR_CHAR = SEPARATOR.charAt( 0 );
88
89
  private final static int GROUP_DELIMITED = 1;
90
  private final static int GROUP_REFERENCE = 2;
91
92
  private final static VariableDecorator VARIABLE_DECORATOR
93
    = new YamlVariableDecorator();
94
95
  private String error;
96
97
  /**
98
   * Compiled version of DEFAULT_REGEX.
99
   */
100
  private final static Pattern REGEX_PATTERN
101
    = Pattern.compile( YamlVariableDecorator.REGEX );
102
103
  /**
104
   * Should be JsonPointer.SEPARATOR, but Jackson YAML uses magic values.
105
   */
106
  private final static char SEPARATOR_YAML = '/';
107
108
  /**
109
   * Start of the Universe (the YAML document node that contains all others).
110
   */
111
  private JsonNode documentRoot;
112
113
  /**
114
   * Map of references to dereferenced field values.
115
   */
116
  private Map<String, String> references;
117
118
  public YamlParser( final InputStream in ) throws IOException {
119
    process( in );
120
  }
121
122
  /**
123
   * Returns the given string with all the delimited references swapped with
124
   * their recursively resolved values.
125
   *
126
   * @param text The text to parse with zero or more delimited references to
127
   * replace.
128
   *
129
   * @return The substituted value.
130
   */
131
  public String substitute( String text ) {
132
    final Matcher matcher = patternMatch( text );
133
    final Map<String, String> map = getReferences();
134
135
    while( matcher.find() ) {
136
      final String key = matcher.group( GROUP_DELIMITED );
137
      final String value = map.get( key );
138
139
      if( value == null ) {
140
        missing( text );
141
      }
142
      else {
143
        text = text.replace( key, value );
144
      }
145
    }
146
147
    return text;
148
  }
149
150
  /**
151
   * Returns all the strings with their values resolved in a flat hierarchy.
152
   * This copies all the keys and resolved values into a new map.
153
   *
154
   * @return The new map created with all values having been resolved,
155
   * recursively.
156
   */
157
  public Map<String, String> createResolvedMap() {
158
    final Map<String, String> map = new HashMap<>( 1024 );
159
160
    resolve( getDocumentRoot(), "", map );
161
162
    return map;
163
  }
164
165
  /**
166
   * Iterate over a given root node (at any level of the tree) and adapt each
167
   * leaf node.
168
   *
169
   * @param rootNode A JSON node (YAML node) to adapt.
170
   * @param map Container that associates definitions with values.
171
   */
172
  private void resolve(
173
    final JsonNode rootNode, final String path, final Map<String, String> map ) {
174
175
    if( rootNode != null ) {
176
      rootNode.fields().forEachRemaining(
177
        (Entry<String, JsonNode> leaf) -> resolve( leaf, path, map )
178
      );
179
    }
180
  }
181
182
  /**
183
   * Recursively adapt each rootNode to a corresponding rootItem.
184
   *
185
   * @param rootNode The node to adapt.
186
   */
187
  private void resolve(
188
    final Entry<String, JsonNode> rootNode,
189
    final String path,
190
    final Map<String, String> map ) {
191
192
    final JsonNode leafNode = rootNode.getValue();
193
    final String key = rootNode.getKey();
194
195
    if( leafNode.isValueNode() ) {
196
      final String value = rootNode.getValue().asText();
197
198
      map.put( VARIABLE_DECORATOR.decorate( path + key ), substitute( value ) );
199
    }
200
201
    if( leafNode.isObject() ) {
202
      resolve( leafNode, path + key + SEPARATOR, map );
203
    }
204
  }
205
206
  /**
207
   * Reads the first document from the given stream of YAML data and returns a
208
   * corresponding object that represents the YAML hierarchy. The calling class
209
   * is responsible for closing the stream. Calling classes should use
210
   * <code>JsonNode.fields()</code> to walk through the YAML tree of fields.
211
   *
212
   * @param in The input stream containing YAML content.
213
   *
214
   * @return An object hierarchy to represent the content.
215
   *
216
   * @throws IOException Could not read the stream.
217
   */
218
  private JsonNode process( final InputStream in ) throws IOException {
219
    final ObjectNode root = (ObjectNode)getObjectMapper().readTree( in );
220
    setDocumentRoot( root );
221
    process( root );
222
    return getDocumentRoot();
223
  }
224
225
  /**
226
   * Iterate over a given root node (at any level of the tree) and process each
227
   * leaf node.
228
   *
229
   * @param root A node to process.
230
   */
231
  private void process( final JsonNode root ) {
232
    root.fields().forEachRemaining( this::process );
233
  }
234
235
  /**
236
   * Process the given field, which is a named node. This is where the
237
   * application does the up-front work of mapping references to their fully
238
   * recursively dereferenced values.
239
   *
240
   * @param field The named node.
241
   */
242
  private void process( final Entry<String, JsonNode> field ) {
243
    final JsonNode node = field.getValue();
244
245
    if( node.isObject() ) {
246
      process( node );
247
    }
248
    else {
249
      final JsonNode fieldValue = field.getValue();
250
251
      // Only basic data types can be parsed into variable values. For
252
      // node structures, YAML has a built-in mechanism.
253
      if( fieldValue.isValueNode() ) {
254
        try {
255
          resolve( fieldValue.asText() );
256
        } catch( StackOverflowError e ) {
257
          setError( "Unresolvable: " + node.textValue() + " = " + fieldValue );
258
        }
259
      }
260
    }
261
  }
262
263
  /**
264
   * Inserts the delimited references and field values into the cache. This will
265
   * overwrite existing references.
266
   *
267
   * @param fieldValue YAML field containing zero or more delimited references.
268
   * If it contains a delimited reference, the parameter is modified with the
269
   * dereferenced value before it is returned.
270
   *
271
   * @return fieldValue without delimited references.
272
   */
273
  private String resolve( String fieldValue ) {
274
    final Matcher matcher = patternMatch( fieldValue );
275
276
    while( matcher.find() ) {
277
      final String delimited = matcher.group( GROUP_DELIMITED );
278
      final String reference = matcher.group( GROUP_REFERENCE );
279
      final String dereference = resolve( lookup( reference ) );
280
281
      fieldValue = fieldValue.replace( delimited, dereference );
282
283
      // This will perform some superfluous calls by overwriting existing
284
      // items in the delimited reference map.
285
      put( delimited, dereference );
286
    }
287
288
    return fieldValue;
289
  }
290
291
  /**
292
   * Inserts a key/value pair into the references map. The map retains
293
   * references and dereferenced values found in the YAML. If the reference
294
   * already exists, this will overwrite with a new value.
295
   *
296
   * @param delimited The variable name.
297
   * @param dereferenced The resolved value.
298
   */
299
  private void put( String delimited, String dereferenced ) {
300
    if( dereferenced.isEmpty() ) {
301
      missing( delimited );
302
    }
303
    else {
304
      getReferences().put( delimited, dereferenced );
305
    }
306
  }
307
308
  /**
309
   * Writes the modified YAML document to standard output.
310
   */
311
  private void writeDocument() throws IOException {
312
    getObjectMapper().writeValue( System.out, getDocumentRoot() );
313
  }
314
315
  /**
316
   * Called when a delimited reference is dereferenced to an empty string. This
317
   * should produce a warning for the user.
318
   *
319
   * @param delimited Delimited reference with no derived value.
320
   */
321
  private void missing( final String delimited ) {
322
    setError( MessageFormat.format( "Missing value for '{0}'.", delimited ) );
323
  }
324
325
  /**
326
   * Returns a REGEX_PATTERN matcher for the given text.
327
   *
328
   * @param text The text that contains zero or more instances of a
329
   * REGEX_PATTERN that can be found using the regular expression.
330
   */
331
  private Matcher patternMatch( String text ) {
332
    return getPattern().matcher( text );
333
  }
334
335
  /**
336
   * Finds the YAML value for a reference.
337
   *
338
   * @param reference References a value in the YAML document.
339
   *
340
   * @return The dereferenced value.
341
   */
342
  private String lookup( final String reference ) {
343
    return getDocumentRoot().at( asPath( reference ) ).asText();
344
  }
345
346
  /**
347
   * Converts a reference (not delimited) to a path that can be used to find a
348
   * value that should exist inside the YAML document.
349
   *
350
   * @param reference The reference to convert to a YAML document path.
351
   *
352
   * @return The reference with a leading slash and its separator characters
353
   * converted to slashes.
354
   */
355
  private String asPath( final String reference ) {
356
    return SEPARATOR_YAML + reference.replace( getDelimitedSeparator(), SEPARATOR_YAML );
357
  }
358
359
  /**
360
   * Sets the parent node for the entire YAML document tree.
361
   *
362
   * @param documentRoot The parent node.
363
   */
364
  private void setDocumentRoot( ObjectNode documentRoot ) {
365
    this.documentRoot = documentRoot;
366
  }
367
368
  /**
369
   * Returns the parent node for the entire YAML document tree.
370
   *
371
   * @return The parent node.
372
   */
373
  protected JsonNode getDocumentRoot() {
374
    return this.documentRoot;
375
  }
376
377
  /**
378
   * Returns the compiled regular expression REGEX_PATTERN used to match
379
   * delimited references.
380
   *
381
   * @return A compiled regex for use with the Matcher.
382
   */
383
  private Pattern getPattern() {
384
    return REGEX_PATTERN;
385
  }
386
387
  /**
388
   * Returns the list of references mapped to dereferenced values.
389
   *
390
   * @return
391
   */
392
  private Map<String, String> getReferences() {
393
    if( this.references == null ) {
394
      this.references = createReferences();
395
    }
396
397
    return this.references;
398
  }
399
400
  /**
401
   * Subclasses can override this method to insert their own map.
402
   *
403
   * @return An empty HashMap, never null.
404
   */
405
  protected Map<String, String> createReferences() {
406
    return new HashMap<>();
407
  }
408
409
  private final class ResolverYAMLFactory extends YAMLFactory {
410
411
    private static final long serialVersionUID = 1L;
412
413
    @Override
414
    protected YAMLGenerator _createGenerator(
415
      final Writer out, final IOContext ctxt ) throws IOException {
416
417
      return new ResolverYAMLGenerator(
418
        ctxt, _generatorFeatures, _yamlGeneratorFeatures, _objectCodec,
419
        out, _version );
420
    }
421
  }
422
423
  private class ResolverYAMLGenerator extends YAMLGenerator {
424
425
    public ResolverYAMLGenerator(
426
      final IOContext ctxt,
427
      final int jsonFeatures,
428
      final int yamlFeatures,
429
      final ObjectCodec codec,
430
      final Writer out,
431
      final DumperOptions.Version version ) throws IOException {
432
433
      super( ctxt, jsonFeatures, yamlFeatures, codec, out, version );
434
    }
435
436
    @Override
437
    public void writeString( final String text )
438
      throws IOException, JsonGenerationException {
439
      super.writeString( substitute( text ) );
440
    }
441
  }
442
443
  private YAMLFactory getYAMLFactory() {
444
    return new ResolverYAMLFactory();
445
  }
446
447
  private ObjectMapper getObjectMapper() {
448
    return new ObjectMapper( getYAMLFactory() );
449
  }
450
451
  /**
452
   * Returns the character used to separate YAML paths within delimited
453
   * references. This will return only the first character of the command line
454
   * parameter, if the default is overridden.
455
   *
456
   * @return A period by default.
457
   */
458
  private char getDelimitedSeparator() {
459
    return SEPARATOR.charAt( 0 );
460
  }
461
462
  private void setError( final String error ) {
463
    this.error = error;
464
  }
465
466
  /**
467
   * Returns the last error message, if any, that occurred during parsing.
468
   *
469
   * @return The error message or the empty string if no error occurred.
470
   */
471
  public String getError() {
472
    return this.error == null ? "" : this.error;
459473
  }
460474
}
M src/main/resources/com/scrivenvar/build.sh
11
#!/bin/bash
22
3
INKSCAPE=/usr/bin/inkscape
3
INKSCAPE="/usr/bin/inkscape"
4
PNG_COMPRESS="optipng"
5
PNG_COMPRESS_OPTS="-o9 *png"
6
ICO_TOOL="icotool"
7
ICO_TOOL_OPTS="-c -o ../../../../../icons/logo.ico logo64.png"
48
59
declare -a SIZES=("16" "32" "64" "128" "256" "512")
610
711
for i in "${SIZES[@]}"; do
812
  # -y: export background opacity 0
913
  $INKSCAPE -y 0 -z -f "logo.svg" -w "${i}" -e "logo${i}.png"
1014
done
15
16
# Compess the PNG images.
17
which $PNG_COMPRESS && $PNG_COMPRESS $PNG_COMPRESS_OPTS
18
19
# Generate an ICO file.
20
which $ICO_TOOL && $ICO_TOOL $ICO_TOOL_OPTS
1121
1222
M src/main/resources/com/scrivenvar/logo128.png
Binary file
M src/main/resources/com/scrivenvar/logo256.png
Binary file
M src/main/resources/com/scrivenvar/logo32.png
Binary file
M src/main/resources/com/scrivenvar/logo512.png
Binary file
M src/main/resources/com/scrivenvar/logo64.png
Binary file