Skip to content

Commit

Permalink
perf(offset + formatting): optimize offset calculation and code forma…
Browse files Browse the repository at this point in the history
…tting

- enable `retainLines` in babel
- format code before and after codemod
  • Loading branch information
phukon committed Sep 16, 2024
1 parent f8db443 commit e8b049b
Show file tree
Hide file tree
Showing 6 changed files with 326 additions and 1,833 deletions.
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1 @@
**/node_modules/**
**/node_modules
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Riki Phukon

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
125 changes: 125 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Docmap

Docmap is a powerful tool for managing documentation comments in source code. It:

1. Extracts specially-prefixed comments from various source files
2. Preserves original code structure while removing these comments
3. Generates a consolidated `README.md` with all extracted comments
4. Creates a sourcemap to trace comments back to their original locations

This approach mirrors how sourcemaps handle JavaScript transformations, providing a seamless way to maintain separate but linked documentation.

### Keeping Your Code Squeaky Clean

Here's the neat part: Docmap swoops in and plucks out those special comments from your source files, then bundles them all up in the README.
What does this mean for you? Your actual code stays nice and tidy, without all those extra comments cluttering things up. But don't worry, you're not losing any of that juicy
documentation – it's all safe and sound in the README. It's like having your cake and eating it too – clean code and thorough docs, all in one go!

## How it works

- Extracts documentation comments from `.js`, `.ts`, `.jsx`, and `.tsx` source files.
- Generates a consolidated `README.md` file with the extracted comments.
- Creates a sourcemap that maps each comment in the `README.md` back to its original source code location.
- Preserves the formatting and structure of the original source code.
- Utilizes Babel, Prettier, and Mozilla's Source Map library for processing.

## Installation

To get started with `docmap`, you'll need to clone this repository and install the required dependencies:


## Usage

1. Mark comments for inclusion in the `README.md` using the specific prefix `#DOCMAP_v0.0.1:`. This prefix can be used in two ways:

a. Block comments: Place these above the relevant code snippet.
```javascript
/**
* #DOCMAP_v0.0.1: This is a block comment
*/
function exampleFunction() {
// Function code here
}
```

b. Inline comments: Add these on the same line as the code they describe.
```javascript
const result = someOperation(); // #DOCMAP_v0.0.1: This is an inline comment
```

Docmap will extract these prefixed comments while preserving your code's structure and readability.
2. **Run the Codemod**: Execute the script to process the source files, extract comments, and generate the `README.md` and sourcemap.
3. **View the Output**: After running the script, you'll find:

- A `README.md` file with all extracted comments.
- A `README.map.json` file containing the sourcemap for those comments.


Example 1
```javascript
import { useReducer } from 'react';
/**
* #DOCMAP_v0.0.1:
* block comment2
*/
export default function TaskApp() {
const [tasks, dispatch] = useReducer(
tasksReducer,
initialTasks
);
function handleAddTask(text) {
dispatch({
type: 'added',
id: nextId++, // #DOCMAP_v0.0.1: inline comment
text: text,
});
}
```

```typescript
interface User {
id: number;
name: string;
email: string;
}
/**
* #DOCMAP_v0.0.1: UserService class handles all user-related operations.
* It's the backbone of our user management system, providing methods for
* adding, retrieving, and updating user data. This abstraction layer
* keeps our user data organized and easily accessible throughout the app.
*/
class UserService {
private users: User[] = [];
/**
* #DOCMAP_v0.0.1: Adds a new user to the system. It's a simple method
* that takes a User object and pushes it into our users array. No
* validation here, but we might want to add some in the future.
*/
addUser(user: User): void {
this.users.push(user); // #DOCMAP_v0.0.1: Simple array push, no bells and whistles
}
getUserById(id: number): User | undefined {
return this.users.find(user => user.id === id); // #DOCMAP_v0.0.1: Returns User or undefined if not found
}
updateUser(id: number, updatedUser: Partial<User>): void {
const index = this.users.findIndex(user => user.id === id);
if (index !== -1) {
this.users[index] = { ...this.users[index], ...updatedUser }; // #DOCMAP_v0.0.1: Merges existing user data with updates
}
}
}
```

## TODO:
- Multiple file support.
- convert to typescript


## Contributing

Contributions are welcome! Please feel free to submit a Pull Request or open an Issue if you have any suggestions or find any bugs.
137 changes: 109 additions & 28 deletions main/index.cjs
Original file line number Diff line number Diff line change
@@ -1,7 +1,18 @@
/**
* Trailing comments are only assigned to the code that is on the same line as themselves.
* Leading comments that are block quotes are assigned to the code that is directly below them.
*
* The lines in babel ast world are ONE indexed, and the columns are ZERO indexed.
* In sourcemap world. Everything is zero indexed (lines and columns)
* A semicolon (;) means skip to next line in the output file.
*/

// **Interpreting the mappings entries (Base 64 VLQ)**
// - [0]: Column index in the compiled file
// - [1]: What original source file the location in the compiled source maps to
// - [2]: Row index in the original source file (i.e. the line number)
// - [3]: Column index in the original source file
const prettier = require('prettier');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generate = require('@babel/generator').default;
Expand All @@ -10,12 +21,45 @@ const fs = require('fs');
const path = require('path');
const { v4: uuidv4 } = require('uuid');
const { SourceMapGenerator } = require('source-map');
const sourceCodeDir = './samples/comment-loc';
const outputCodeDir = './samples/comment-loc';
const sourceCodeDir = './test';
const readmeFilePath = './README.md';
const sourceMapFilePath = './README.map.json';
let collectedComments = [];
let commentLocations = [];

const defaultPrettierConfig = {
semi: true,
singleQuote: false,
trailingComma: 'es5',
};

async function formatWithPrettier(filePath) {
const code = fs.readFileSync(filePath, 'utf-8');
const resolvedConfig = await prettier.resolveConfig(filePath);
const options = resolvedConfig
? { ...resolvedConfig, filepath: filePath }
: { ...defaultPrettierConfig, filepath: filePath };
const formatted = await prettier.format(code, options);
fs.writeFileSync(filePath, formatted);
}

async function formatDirectory(dir) {
const files = fs.readdirSync(dir);
for (const file of files) {
const filePath = path.join(dir, file);
if (file === 'node_modules') {
continue;
}
if (fs.statSync(filePath).isDirectory()) {
await formatDirectory(filePath);
} else if (
['.jsx', '.tsx', '.js', '.ts'].includes(path.extname(filePath))
) {
await formatWithPrettier(filePath);
}
}
}

class FileProcessor {
constructor(sourceDir, outputDir) {
this.sourceDir = sourceDir;
Expand Down Expand Up @@ -61,15 +105,16 @@ class FileProcessor {
collectedComments.push({
docmapId: comment._docmapId,
text: cleanedComment,
isMultiline: comment.type === 'CommentBlock',
});
}
return !isTargetComment;
});

const visitor = {
Program(path) {
Program: (path) => {
path.traverse({
enter(nodePath) {
enter: (nodePath) => {
if (nodePath.node.leadingComments) {
nodePath.node.leadingComments =
nodePath.node.leadingComments.filter((comment) => {
Expand All @@ -78,14 +123,15 @@ class FileProcessor {
comment.value.includes('#DOCMAP_v0.0.1:')
) {
const location = nodePath.node.loc.start;
// const locationString = `Line: ${location.line}, Column: ${location.column}`;
const currentId = comment._docmapId;
const hasBlankLineBefore = this.checkBlankLineBefore(comment, code);
commentLocations.push({
docmapId: currentId,
filePath,
loc: location,
hasBlankLineBefore,
});
// a comment can be a leading comment for one node and trailing comment for another
// we are doing this again because a comment can be a leading comment for one node and trailing comment for another
path.traverse({
enter(innerPath) {
if (innerPath.node.leadingComments) {
Expand Down Expand Up @@ -116,7 +162,6 @@ class FileProcessor {
const location = nodePath.node.loc.start;
const endLine = nodePath.node.loc.end.line;
if (comment.loc.start.line === endLine) {
// const locationString = `Line: ${location.line}, Column: ${location.column}`;
const currentId = comment._docmapId;
commentLocations.push({
docmapId: currentId,
Expand Down Expand Up @@ -154,10 +199,28 @@ class FileProcessor {

traverse(ast, visitor);

const { code: modifiedCode } = generate(ast, {}, code);
const { code: modifiedCode } = generate(ast, { retainLines: true }, code);
this.writeOutputFile(filePath, modifiedCode);
}

/**
* We are doing this because
* - If a comment block is sandwiched between two code snippets without blank lines,
* it becomes a single blank line after the codemod.
*
* - If there are blank lines before the comment, they're consolidated
* into one blank line after applygin the formatter.
*/
checkBlankLineBefore(comment, sourceCode) {
const lines = sourceCode.split('\n');
const commentStartLine = comment.loc.start.line;
if (commentStartLine > 1) {
const previousLine = lines[commentStartLine - 2]; // -2 because array is 0-indexed and we want the line before the comment
return previousLine.trim() === '';
}
return false;
}

traverseDirectory(dir) {
const files = fs.readdirSync(dir);
files.forEach((file) => {
Expand Down Expand Up @@ -210,36 +273,46 @@ class FileProcessor {
console.log(`Comments written to ${readmeFilePath}`);
}


// **Interpreting the mappings entries (Base 64 VLQ)**
// - [0]: Column index in the compiled file
// - [1]: What original source file the location in the compiled source maps to
// - [2]: Row index in the original source file (i.e. the line number)
// - [3]: Column index in the original source file

generateSourceMap(sourceMapFilePath) {
const map = new SourceMapGenerator({ file: readmeFilePath });

let line = 1;
let offset = 0;
collectedComments.forEach((comment) => {
const location = commentLocations.find(
(loc) => loc.docmapId === comment.docmapId
);
if (location) {
// Increase offset by 1 if there's a blank line before the comment
if (location.hasBlankLineBefore) {
offset += 1;
}

const lines = comment.text.split('\n');
if (lines.length > 1) {
offset += lines.length + 3; // the starting chars + the tag + the ending chars = 3
} else if (lines.length === 1 && comment.isMultiline) { // Inline comments and Comment blocks can still have one line after parsing
offset += 3;
}
lines.forEach((_, i) => {
map.addMapping({
let mapping = {
generated: { line: line + i, column: 0 },
source: location.filePath,
original: { line: location.loc.line, column: 0 },
original: { line: location.loc.line - offset, column: 0 },
name: null,
});
};
map.addMapping(mapping);
// TO DEBUG AST
// console.log('Added mapping:', {
// without_offset: location.loc.line,
// offset: offset,
// generated: mapping.generated,
// source: mapping.source,
// original: mapping.original,
// name: mapping.name,
// });
});
line += lines.length + 1 // +1 for the blank line between comments
const sourceContent = fs.readFileSync(
'./samples/comment-loc/c.tsx',
'utf-8'
);
line += lines.length + 1; // +1 for the blank line between comments
const sourceContent = fs.readFileSync(location.filePath, 'utf-8');
map.setSourceContent(location.filePath, sourceContent);
}
});
Expand All @@ -249,7 +322,15 @@ class FileProcessor {
}
}

const fileProcessor = new FileProcessor(sourceCodeDir, outputCodeDir);
fileProcessor.traverseDirectory(sourceCodeDir);
fileProcessor.writeCollectedComments(readmeFilePath);
fileProcessor.generateSourceMap(sourceMapFilePath);
(async () => {
console.log('Running Prettier Formatter...');
await formatDirectory(sourceCodeDir);
console.log('Running Codemod...');
const fileProcessor = new FileProcessor(sourceCodeDir, sourceCodeDir);
fileProcessor.traverseDirectory(sourceCodeDir);
fileProcessor.writeCollectedComments(readmeFilePath);
fileProcessor.generateSourceMap(sourceMapFilePath);

console.log('Running Prettier Formatter After Codemod...');
await formatDirectory(sourceCodeDir);
})();
Loading

0 comments on commit e8b049b

Please sign in to comment.