extensions/interactions/CodeRepl/directives/oppia-interactive-code-repl.component.ts

Summary

Maintainability
B
6 hrs
Test Coverage
// Copyright 2014 The Oppia Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//      http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS-IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

/**
 * @fileoverview Directive for the CodeRepl interaction.
 *
 * IMPORTANT NOTE: The naming convention for customization args that are passed
 * into the directive is: the name of the parameter, followed by 'With',
 * followed by the name of the arg.
 */

import {
  AfterViewInit,
  ChangeDetectorRef,
  Component,
  Input,
  OnDestroy,
  OnInit,
  ViewChild,
} from '@angular/core';
import {downgradeComponent} from '@angular/upgrade/static';

import {CodemirrorComponent} from '@ctrl/ngx-codemirror';
import {Subscription} from 'rxjs';

import {CodeReplCustomizationArgs} from 'interactions/customization-args-defs';
import {InteractionAttributesExtractorService} from 'interactions/interaction-attributes-extractor.service';
import {CurrentInteractionService} from 'pages/exploration-player-page/services/current-interaction.service';
import {PlayerPositionService} from 'pages/exploration-player-page/services/player-position.service';
import {CodeReplRulesService} from './code-repl-rules.service';

@Component({
  selector: 'oppia-interactive-code-repl',
  templateUrl: './code-repl-interaction.component.html',
  styleUrls: [],
})
export class InteractiveCodeReplComponent
  implements OnInit, AfterViewInit, OnDestroy
{
  @Input() lastAnswer;
  @Input() languageWithValue;
  @Input() placeholderWithValue;
  @Input() preCodeWithValue;
  @Input() postCodeWithValue;
  @ViewChild(CodemirrorComponent) codeMirrorComponent: CodemirrorComponent;
  componentSubscriptions = new Subscription();
  hasLoaded: boolean = true;
  code: string;
  evaluation: string;
  fullError: string;
  output: string;
  interactionIsActive: boolean;
  language: string;
  placeholder: string;
  preCode: string;
  postCode: string;

  editorOptions = {
    lineNumbers: true,
    indentWithTabs: true,
    indentUnit: 4,
    mode: 'python',
    extraKeys: {
      Tab: (cm: CodeMirror.Editor): void => {
        var spaces = Array(cm.getOption('indentUnit') + 1).join(' ');
        cm.replaceSelection(spaces);
        // Move the cursor to the end of the selection.
        var endSelectionPos = cm.getDoc().getCursor('head');
        cm.getDoc().setCursor(endSelectionPos);
      },
    },
    theme: 'preview default',
  };

  constructor(
    private changeDetectionRef: ChangeDetectorRef,
    private currentInteractionService: CurrentInteractionService,
    private codeReplRulesService: CodeReplRulesService,
    private interactionAttributesExtractorService: InteractionAttributesExtractorService,
    private playerPositionService: PlayerPositionService
  ) {}

  ngOnInit(): void {
    this.componentSubscriptions.add(
      this.playerPositionService.onNewCardAvailable.subscribe(
        () => (this.interactionIsActive = false)
      )
    );
    const {language, placeholder, preCode, postCode} =
      this.interactionAttributesExtractorService.getValuesFromAttributes(
        'CodeRepl',
        {
          languageWithValue: this.languageWithValue,
          placeholderWithValue: this.placeholderWithValue,
          preCodeWithValue: this.preCodeWithValue,
          postCodeWithValue: this.postCodeWithValue,
        }
      ) as CodeReplCustomizationArgs;

    this.interactionIsActive = this.lastAnswer === null;
    this.language = language.value;
    this.placeholder = placeholder.value;
    this.preCode = preCode.value;
    this.postCode = postCode.value;

    // Make sure this.preCode ends with a newline.
    if (this.preCode.trim().length === 0) {
      this.preCode = '';
    } else if (this.preCode.slice(-1) !== '\n') {
      this.preCode += '\n';
    }

    // Make sure this.placeholder ends with a newline.
    if (this.placeholder.slice(-1) !== '\n') {
      this.placeholder += '\n';
    }

    this.hasLoaded = false;

    // Keep the code string given by the user and the stdout from the
    // evaluation until sending them back to the server.
    if (this.interactionIsActive) {
      this.code = this.preCode + this.placeholder + this.postCode;
      this.output = '';
    } else {
      this.code = this.lastAnswer.code;
      this.output = this.lastAnswer.output;
    }

    // Configure Skulpt.
    Sk.configure({
      output: out => {
        // This output function is called continuously throughout the
        // runtime of the script.
        this.output += out;
      },
      read: name => {
        // This function is called when a builtin module is imported.
        if (Sk.builtinFiles.files[name] === undefined) {
          // If corresponding module is not present then,
          // removal of this block also results in failure of import.
          throw new Error('module ' + name + ' not found');
        }
        return Sk.builtinFiles.files[name];
      },
      timeoutMsg: () => {
        this.sendResponse('', 'timeout');
      },
      execLimit: 10000,
    });

    this.currentInteractionService.registerCurrentInteraction(
      () => this.submitAnswer(),
      null
    );
  }

  ngAfterViewInit(): void {
    const runAfterTimeout = () => {
      this.initMarkers(this.codeMirrorComponent.codeMirror);
      this.hasLoaded = true;
      this.changeDetectionRef.detectChanges();
    };
    setTimeout(() => {
      runAfterTimeout();
    }, 0);
  }

  runAndSubmitCode(codeInput: string): void {
    this.runCode(codeInput, (evaluation, err) => {
      this.sendResponse(evaluation, err);
    });
  }

  private submitAnswer() {
    this.runAndSubmitCode(this.code);
  }

  onEditorChange(code: string): void {
    this.code = code;
  }

  runCode(
    codeInput: string,
    onFinishRunCallback?: (x: string, error: string) => void
  ): void {
    this.code = codeInput;
    this.output = '';

    // Evaluate the program asynchronously using Skulpt.
    Sk.misceval
      .asyncToPromise(() => {
        Sk.importMainWithBody('<stdin>', false, codeInput, true);
      })
      .then(
        () => {
          // Finished evaluating.
          this.evaluation = '';
          this.fullError = '';

          if (onFinishRunCallback) {
            onFinishRunCallback('', '');
          }
        },
        err => {
          if (!(err instanceof Sk.builtin.TimeLimitError)) {
            this.evaluation = '';
            this.fullError = String(err);

            if (onFinishRunCallback) {
              onFinishRunCallback('', String(err));
            }
          }
        }
      );
  }

  private initMarkers(editor) {
    var doc = editor.getDoc();

    // The -1 here is because prepended code ends with a newline.
    var preCodeNumLines = this.preCode.split('\n').length - 1;
    var postCodeNumLines = this.postCode.split('\n').length;
    var fullCodeNumLines = this.code.split('\n').length;
    var userCodeNumLines =
      fullCodeNumLines - preCodeNumLines - postCodeNumLines;

    // Mark pre- and post- code as uneditable, and give it some styling.
    var markOptions = {
      atomic: false,
      readOnly: true,
      inclusiveLeft: true,
      inclusiveRight: true,
    };

    if (this.preCode.length !== 0) {
      doc.markText(
        {
          line: 0,
          ch: 0,
        },
        {
          line: preCodeNumLines,
          ch: 0,
        },
        angular.extend({}, markOptions, {
          inclusiveRight: false,
        })
      );

      for (var i = 0; i < preCodeNumLines; i++) {
        editor.addLineClass(i, 'text', 'code-repl-noneditable-line');
      }
    }

    if (this.postCode.length !== 0) {
      doc.markText(
        {
          line: preCodeNumLines + userCodeNumLines,
          ch: 0,
        },
        {
          line: fullCodeNumLines,
          ch: 0,
        },
        markOptions
      );

      for (var i = 0; i < postCodeNumLines; i++) {
        editor.addLineClass(
          preCodeNumLines + userCodeNumLines + i,
          'text',
          'code-repl-noneditable-line'
        );
      }
    }
  }

  sendResponse(evaluation: string, err: string): void {
    this.currentInteractionService.onSubmit(
      {
        // Replace tabs with 2 spaces.
        // TODO(#12712): (1) CodeRepl interaction.
        code: this.code.replace(/\t/g, '  ') || '',
        output: this.output,
        evaluation: this.evaluation,
        error: err || '',
      },
      this.codeReplRulesService
    );
  }

  ngOnDestroy(): void {
    this.componentSubscriptions.unsubscribe();
  }
}

angular.module('oppia').directive(
  'oppiaInteractiveCodeRepl',
  downgradeComponent({
    component: InteractiveCodeReplComponent,
  }) as angular.IDirectiveFactory
);