diff --git a/components/annotated-source.tsx b/components/annotated-source.tsx new file mode 100644 index 0000000..5c1abe3 --- /dev/null +++ b/components/annotated-source.tsx @@ -0,0 +1,192 @@ +import { IGACAnnotation } from '../services/gac-annotations'; +import Highlight from 'react-highlight'; +import { useRef, useState, useMemo, useLayoutEffect } from 'react'; +import ReactMarkdown from 'react-markdown/with-html'; + +interface IAnnotatedSourceProps { + code: string; + annotations: IGACAnnotation[]; +} + +function annotationLines(annotation: IGACAnnotation) { + const result = []; + for (let line = annotation.line; line <= annotation.endLine; line++) { + result.push(line); + } + return result; +} + +function AnnotationMarkers({ annotations }: { annotations: IGACAnnotation[] }) { + const result = []; + for (const annotation of annotations) { + for (const line of annotationLines(annotation)) { + result[line - 1] = ( +
+   +
+ ); + } + } + for (let line = 0; line < result.length; line++) { + result[line] = result[line] ??
 
; + } + return <>{result}; +} + +export function AnnotatedSource({ code, annotations }: IAnnotatedSourceProps) { + const codeBoxRef = useRef(null); + const markersRef = useRef(null); + + const [activeAnnotation, setActiveAnnotation] = useState(null); + + const highlightedCode = useMemo(() => {code}, [code]); + const annotatedLines = useMemo(() => { + const result = new Map(); + for (const annotation of annotations) { + for (const line of annotationLines(annotation)) { + result.set(line, annotation); + } + } + return result; + }, annotations); + + const [codeLineHeight, setCodeLineHeight] = useState(0); + const codeLineOffset = (line: number) => (line - 1) * codeLineHeight; + useLayoutEffect(() => { + const measure = () => + setCodeLineHeight( + markersRef.current?.firstElementChild?.getBoundingClientRect()?.height || 0, + ); + measure(); + window.addEventListener('resize', measure); + return () => window.removeEventListener('resize', measure); + }, [markersRef.current]); + + const handleMouseOver = (e: React.MouseEvent) => { + const codeBox = codeBoxRef.current; + if (!codeBox) { + return; + } + const { target } = e; + if (target instanceof HTMLElement && target.closest('.annotation-info')) { + return; + } + if (codeLineHeight) { + const line = Math.floor((e.pageY - codeBox.offsetTop) / codeLineHeight) + 1; + setActiveAnnotation(annotatedLines.get(line) ?? null); + } + }; + + const annotationOffset = codeLineOffset(activeAnnotation?.line ?? 1); + return ( +
setActiveAnnotation(null)} + ref={codeBoxRef} + > +
+   +
+
+
+
+ +
+ {highlightedCode} + {activeAnnotation && ( +
+ (url.startsWith('#') ? '' : '_blank')} + /> +
+ )} + +
+ ); +} diff --git a/content/simon/sketch.ino b/content/simon/sketch.ino index 02b2dc3..ac63d8d 100644 --- a/content/simon/sketch.ino +++ b/content/simon/sketch.ino @@ -3,37 +3,70 @@ Copyright (C) 2016, Uri Shaked - Licensed under the MIT License. + Released under the MIT License. */ +/* gac: `pitches.h` defines constants for the different music notes */ #include "pitches.h" /* Constants - define pin numbers for LEDs, buttons and speaker, and also the game tones: */ -char ledPins[] = {9, 10, 11, 12}; -char buttonPins[] = {2, 3, 4, 5}; +/* gac:start + ✅ Define pin numbers as constants at the beginning of the program +*/ +const byte ledPins[] = {9, 10, 11, 12}; +const byte buttonPins[] = {2, 3, 4, 5}; #define SPEAKER_PIN 8 +/* gac:end */ +/* gac: + ✅ Defining the max game length as a constant makes it clear + what this "magic" number controls. */ #define MAX_GAME_LENGTH 100 -int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5}; +/* gac: The tone for each button is stored in an array, + so we can easily match each LED, button and their corresponding + tone by looking at a specific index of the `ledPins`, `buttonPins`, + and `gameTones` arrays. +*/ +const int gameTones[] = { NOTE_G3, NOTE_C4, NOTE_E4, NOTE_G5}; /* Global variales - store the game state */ +/* gac:start + ✅ It's a good pratice to initialize all variables to a default value when + delcaring them. + + We use `gameSequence` to keep track of the current color sequence + that the user has to repeat, and `gameIndex` tells us how long the + sequence is, so we know how many cells of the array we should look at. +*/ byte gameSequence[MAX_GAME_LENGTH] = {0}; byte gameIndex = 0; +/* gac:end */ /** Set up the Arduino board and initialize Serial communication */ void setup() { Serial.begin(9600); - for (int i = 0; i < 4; i++) { + /* gac:start + Defining the pins for the LEDs and Buttons in an array allows + us to initialize them using a `for` loop, instead of calling + the `pinMode()` function for each individual button / LED. + */ + for (byte i = 0; i < 4; i++) { pinMode(ledPins[i], OUTPUT); pinMode(buttonPins[i], INPUT_PULLUP); } + /* gac:end */ pinMode(SPEAKER_PIN, OUTPUT); // The following line primes the random number generator. // It assumes pin A0 is floating (disconnected): + /* gac: In arduino, the `random()` function will return the sequence + of numbers every time you start your program, unless you call + `randomSeed()` with a different value each time. To learn more about + this trick, check out the ["Making random() more Random" + video](https://www.youtube.com/watch?v=FwnXqZB2eo8). */ randomSeed(analogRead(A0)); } @@ -53,7 +86,7 @@ void lightLedAndPlaySound(byte ledIndex) { */ void playSequence() { for (int i = 0; i < gameIndex; i++) { - char currentLed = gameSequence[i]; + byte currentLed = gameSequence[i]; lightLedAndPlaySound(currentLed); delay(50); } @@ -64,8 +97,8 @@ void playSequence() { and returns the index of that button */ byte readButton() { - for (;;) { - for (int i = 0; i < 4; i++) { + while (true) { + for (byte i = 0; i < 4; i++) { byte buttonPin = buttonPins[i]; if (digitalRead(buttonPin) == LOW) { return i; @@ -83,6 +116,7 @@ void gameOver() { Serial.println(gameIndex - 1); gameIndex = 0; delay(200); + // Play a Wah-Wah-Wah-Wah sound tone(SPEAKER_PIN, NOTE_DS5); delay(300); @@ -90,7 +124,7 @@ void gameOver() { delay(300); tone(SPEAKER_PIN, NOTE_CS5); delay(300); - for (int i = 0; i < 200; i++) { + for (byte i = 0; i < 200; i++) { tone(SPEAKER_PIN, NOTE_C5 + (i % 20 - 10)); delay(5); } @@ -104,8 +138,8 @@ void gameOver() { */ void checkUserSequence() { for (int i = 0; i < gameIndex; i++) { - char expectedButton = gameSequence[i]; - char actualButton = readButton(); + byte expectedButton = gameSequence[i]; + byte actualButton = readButton(); lightLedAndPlaySound(actualButton); if (expectedButton == actualButton) { /* good */ @@ -142,6 +176,21 @@ void loop() { // Add a random color to the end of the sequence gameSequence[gameIndex] = random(0, 4); gameIndex++; + /* gac:start + ✅ Array boundary check + + The C++ language does not protect us from accessing data beyond the + end of the array. Therefore, our code must always check that we are + still within the array boundaries. Otherwise, we'll get unexpected + behavior and the program may crash. + + In this case, we make sure that `gameIndex` always stays below + `MAX_GAME_LENGTH`, which is the size of the `gameSequence` array. + */ + if (gameIndex >= MAX_GAME_LENGTH) { + gameIndex = MAX_GAME_LENGTH - 1; + } + /* gac:end */ playSequence(); checkUserSequence(); diff --git a/pages/api/download-project/[project].ts b/pages/api/download-project/[project].ts index d7d0fad..248ac85 100644 --- a/pages/api/download-project/[project].ts +++ b/pages/api/download-project/[project].ts @@ -2,6 +2,7 @@ import { NextApiRequest, NextApiResponse } from 'next'; import { promisify } from 'util'; import ZipStream from 'zip-stream'; import { getProjectCode } from '../../../services/projects'; +import { extractCodeAnnotations } from '../../../services/gac-annotations'; export default async (req: NextApiRequest, res: NextApiResponse) => { const { @@ -17,7 +18,8 @@ export default async (req: NextApiRequest, res: NextApiResponse) => { zip.pipe(res); for (const file of await getProjectCode(projectId)) { - await promisify(zip.entry).call(zip, file.code, { name: file.name }); + const { code } = extractCodeAnnotations(file.code); + await promisify(zip.entry).call(zip, code, { name: file.name }); } zip.finalize(); }; diff --git a/pages/projects/[id].tsx b/pages/projects/[id].tsx index bdefa71..e28b3f2 100644 --- a/pages/projects/[id].tsx +++ b/pages/projects/[id].tsx @@ -3,12 +3,14 @@ import { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { ParsedUrlQuery } from 'querystring'; import React from 'react'; -import Highlight from 'react-highlight'; import ReactMarkdown from 'react-markdown/with-html'; +import { AnnotatedSource } from '../../components/annotated-source'; import { GlobalStyles } from '../../components/global-styles'; import { Header } from '../../components/header'; import { LinkIconButton } from '../../components/link-icon-button'; import { ISideNavLink, SideNav } from '../../components/side-nav'; +import { reportEvent } from '../../services/analytics'; +import { extractCodeAnnotations, IGACAnnotation } from '../../services/gac-annotations'; import { getProject, getProjectCode, @@ -20,19 +22,22 @@ import { projectFileURL } from '../../services/urls'; import { HeadingRenderer } from '../../utils/heading-renderer'; import { headingToId } from '../../utils/heading-to-id'; import { extractHeadings } from '../../utils/markdown-utils'; -import { reportEvent } from '../../services/analytics'; interface ProjectPageParams extends ParsedUrlQuery { id: string; } +interface IAnnotatedSourceFile extends IProjectSourceFile { + annotations: IGACAnnotation[]; +} + interface ProjectPageProps { id: string; name: string; author?: string; description?: string; simulation: string | null; - code: IProjectSourceFile[]; + code: IAnnotatedSourceFile[]; text: string; } @@ -132,7 +137,7 @@ export default function ProjectPage(props: ProjectPageProps) { {props.code.map((file) => (

{file.name}

- {file.code} +
))} {props.simulation && ( @@ -226,6 +231,10 @@ export const getStaticProps: GetStaticProps throw new Error('Missing post id'); } const project = await getProject(params.id); + const annotatedCode = (await getProjectCode(params.id)).map((sourceFile) => { + const { code, annotations } = extractCodeAnnotations(sourceFile.code); + return { ...sourceFile, code, annotations }; + }); return { props: { id: params.id, @@ -234,7 +243,7 @@ export const getStaticProps: GetStaticProps description: project.description, simulation: project.simulation ?? null, text: await getProjectText(params.id), - code: await getProjectCode(params.id), + code: annotatedCode, }, }; }; diff --git a/services/gac-annotations.spec.ts b/services/gac-annotations.spec.ts new file mode 100644 index 0000000..4c385f4 --- /dev/null +++ b/services/gac-annotations.spec.ts @@ -0,0 +1,82 @@ +import { extractCodeAnnotations } from './gac-annotations'; + +describe('extractCodeAnnotations', () => { + it('should split the given source file into Code and GAC annotations', () => { + const input = ` + /* gac: simple annotation */ + int speakerPin = 7; + + /* gac: This annotation applies for + a single line + */ + /* This is a standard comment */ + void setup() {} + + /* gac:start + This is the loop function... + */ + void loop() { + + } + /* gac:end */ + `; + + const expectedCode = ` + int speakerPin = 7; + + /* This is a standard comment */ + void setup() {} + + void loop() { + + } + `; + + expect(extractCodeAnnotations(input)).toEqual({ + code: expectedCode, + annotations: [ + { + line: 2, + endLine: 2, + value: 'simple annotation', + }, + { + line: 4, + endLine: 4, + value: 'This annotation applies for\na single line', + }, + { line: 7, endLine: 9, value: 'This is the loop function...' }, + ], + }); + }); + + it('should handle CRLF in annotations', () => { + const input = '/* gac: hello\r\nworld */\r\nTest'; + + expect(extractCodeAnnotations(input)).toEqual({ + code: 'Test', + annotations: [ + { + line: 1, + endLine: 1, + value: 'hello\nworld', + }, + ], + }); + }); + + it('should correctly parse single line gac:start annotation', () => { + const input = '/*gac:start single line*/\nTest\nLine 2\n/* gac:end*/'; + + expect(extractCodeAnnotations(input)).toEqual({ + code: 'Test\nLine 2', + annotations: [ + { + line: 1, + endLine: 2, + value: 'single line', + }, + ], + }); + }); +}); diff --git a/services/gac-annotations.ts b/services/gac-annotations.ts new file mode 100644 index 0000000..7e55cb7 --- /dev/null +++ b/services/gac-annotations.ts @@ -0,0 +1,74 @@ +export interface IGACAnnotation { + line: number; + endLine: number; + value: string; +} + +const keywords = { + gac: 'gac:', + gacStart: 'gac:start', + gacEnd: 'gac:end', +} as const; + +export function extractCodeAnnotations(source: string) { + const code = []; + const annotations: IGACAnnotation[] = []; + let lineNumber = 1; + let currentIndent = 0; + let currentAnnotation: IGACAnnotation | null = null; + let insideComment = false; + let insideSection = false; + for (const line of source.split('\n')) { + const trimmed = line.trim(); + if (trimmed.startsWith('/*')) { + const firstWord = trimmed.substr(2).trim().split(/[ *]/)[0]; + if (firstWord === keywords.gac || firstWord === keywords.gacStart) { + insideComment = true; + insideSection = firstWord === keywords.gacStart; + const content = trimmed + .substr(trimmed.indexOf(firstWord) + firstWord.length) + .trim() + .replace(/\s*\*\/.*$/s, ''); + currentAnnotation = { + line: lineNumber, + endLine: lineNumber, + value: content, + }; + annotations.push(currentAnnotation); + if (trimmed.includes('*/')) { + insideComment = false; + if (!insideSection) { + currentAnnotation = null; + } + } + currentIndent = line.indexOf(firstWord); + continue; + } + if (firstWord === keywords.gacEnd && currentAnnotation) { + currentAnnotation.endLine = lineNumber - 1; + currentAnnotation = null; + continue; + } + } + if (insideComment && currentAnnotation) { + const content = line + .replace(new RegExp(`^ {0,${currentIndent}}`), '') + .replace(/\s*\*\/.*$/s, ''); + currentAnnotation.value += '\n' + content; + if (trimmed.startsWith('*/') || trimmed.endsWith('*/')) { + currentAnnotation.value = currentAnnotation.value.trim(); + insideComment = false; + if (!insideSection) { + currentAnnotation = null; + } + } + continue; + } + code.push(line); + lineNumber++; + } + return { + code: code.join('\n'), + annotations, + }; +}