hanlint 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -14,6 +14,7 @@
14
14
  npx hanlint 글.md
15
15
  npx hanlint docs/ --preset docs
16
16
  npx hanlint 글.md --format json
17
+ npx hanlint sheet src/ --preset screen
17
18
  ```
18
19
 
19
20
  인자 없이 실행하면 현재 폴더의 파일을 바탕으로 시작 안내를 보여 준다.
package/data/version.json CHANGED
@@ -1 +1 @@
1
- {"version": "0.0.12"}
1
+ {"version": "0.0.13"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hanlint",
3
- "version": "0.0.12",
3
+ "version": "0.0.13",
4
4
  "description": "한국어 글 검사기. Korean prose linter for Markdown: translationese, noun piles, double passives, weak structure. Zero dependencies",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli/help.js CHANGED
@@ -23,6 +23,7 @@ export function rootHelp() {
23
23
  hanlint spec --preset <종류> --chars 800
24
24
  쓰기 전 숫자 사양을 만든다
25
25
  hanlint primer --preset <종류> 쓰기 전에 규칙의 고치는 법과 본보기를 한 장으로 본다
26
+ hanlint sheet src/ --preset screen 소스의 화면 글을 표 하나로 떨군다. 고친 표는 sheet apply 로 되돌려 쓴다
26
27
 
27
28
  추가 명령
28
29
  baseline, print
package/src/cli/main.js CHANGED
@@ -46,7 +46,9 @@ import { exemplarFor } from "../data/exemplars.js";
46
46
  import { patterns, patternsAvoiding } from "../data/patterns.js";
47
47
  import { HAPNIDA, REGISTERS } from "../analysis/grammar/index.js";
48
48
  import { CATEGORY_TITLES, MECHANISMS, ruleCategory, ruleFix, ruleMechanism, runAll } from "../rules/registry.js";
49
- import { MARKDOWN, SKIPPED_FOLDERS, markdownUnder } from "./walk.js";
49
+ import { MARKDOWN, SKIPPED_FOLDERS, isSkipped, markdownUnder } from "./walk.js";
50
+ import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "../document/sourceText.js";
51
+ import { parseSheet, renderSheet, renderSheetJson } from "../report/sheet.js";
50
52
  import { welcome } from "./welcome.js";
51
53
  import { rootHelp } from "./help.js";
52
54
  import { renderCompact } from "../report/compactReport.js";
@@ -57,7 +59,7 @@ import { renderText } from "../report/textReport.js";
57
59
  import { renderWritingSpec, writingSpec } from "../report/writingSpec.js";
58
60
  import { exemplarInRegister, patternInRegister } from "../report/registerMatch.js";
59
61
 
60
- const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch"];
62
+ const COMMANDS = ["lint", "fix", "print", "rules", "explain", "patterns", "primer", "spec", "hook", "baseline", "doctor", "init", "contract", "check", "verify-patch", "sheet"];
61
63
  const PYTHON_ONLY = [
62
64
  "audit",
63
65
  "map",
@@ -130,6 +132,7 @@ const OPTION_KINDS = {
130
132
  "--chars": "value",
131
133
  "--baseline": "optional",
132
134
  "--prune": "flag",
135
+ "--all": "flag",
133
136
  };
134
137
 
135
138
  class UsageError extends Error {}
@@ -982,6 +985,106 @@ function runInit(args) {
982
985
  return 0;
983
986
  }
984
987
 
988
+ /** 폴더 아래 소스 파일. 뜻은 파이썬 cli/commands/sheet.py 의 sourceUnder 가 소유한다. @param {string} folder @returns {string[]} */
989
+ function sourceUnder(folder) {
990
+ /** @type {string[]} */
991
+ const found = [];
992
+ for (const name of readdirSync(folder).sort()) {
993
+ const path = join(folder, name);
994
+ if (statSync(path).isDirectory()) {
995
+ if (!isSkipped(name)) found.push(...sourceUnder(path));
996
+ } else if (SOURCE_SUFFIXES.includes(extname(name).toLowerCase())) found.push(path);
997
+ }
998
+ return found.sort();
999
+ }
1000
+
1001
+ /** 작업 폴더 기준 상대 경로, 구분자는 `/`. 파이썬 relativeLabel 과 같은 글자를 낸다. @param {string} path */
1002
+ function relativeLabel(path) {
1003
+ const rel = relative(process.cwd(), resolve(path));
1004
+ const label = !rel || rel.startsWith("..") || isAbsolute(rel) ? path : rel;
1005
+ return label.split(sep).join("/").replaceAll("\\", "/");
1006
+ }
1007
+
1008
+ /** 표의 고침을 파일에 쓴다. 뜻은 파이썬 applySheet 가 소유한다. @param {string} sheetPath @param {boolean} dryRun @returns {[string[], string[]]} */
1009
+ function applySheet(sheetPath, dryRun) {
1010
+ const parsed = parseSheet(readFileSync(sheetPath, "utf-8"));
1011
+ /** @type {string[]} */
1012
+ const applied = [];
1013
+ const failed = [...parsed.problems];
1014
+ /** @type {Map<string, import("../report/sheet.js").SheetRow[]>} */
1015
+ const byFile = new Map();
1016
+ for (const row of parsed.rows) {
1017
+ if (!byFile.has(row.file)) byFile.set(row.file, []);
1018
+ /** @type {import("../report/sheet.js").SheetRow[]} */ (byFile.get(row.file)).push(row);
1019
+ }
1020
+ for (const file of [...byFile.keys()].sort()) {
1021
+ const rows = /** @type {import("../report/sheet.js").SheetRow[]} */ (byFile.get(file));
1022
+ if (!existsSync(file)) {
1023
+ failed.push(...rows.map((row) => `${row.file}:${row.line}: 파일이 없다`));
1024
+ continue;
1025
+ }
1026
+ const lines = readFileSync(file, "utf-8").split("\n");
1027
+ let changed = false;
1028
+ for (const row of rows) {
1029
+ const place = `${row.file}:${row.line}`;
1030
+ if (row.line < 1 || row.line > lines.length) {
1031
+ failed.push(`${place}: 그 줄이 없다`);
1032
+ continue;
1033
+ }
1034
+ const [newLine, count] = replaceLiteral(lines[row.line - 1], row.text, row.fix);
1035
+ if (count !== 1) {
1036
+ failed.push(`${place}: 글이 그 줄에 ${count}번 있다. 한 번이어야 바꾼다`);
1037
+ continue;
1038
+ }
1039
+ lines[row.line - 1] = newLine;
1040
+ changed = true;
1041
+ applied.push(`${place}: ${row.text} -> ${row.fix}`);
1042
+ }
1043
+ if (changed && !dryRun) writeFileSync(file, lines.join("\n"), "utf-8");
1044
+ }
1045
+ return [applied, failed];
1046
+ }
1047
+
1048
+ /** `hanlint sheet 폴더/ --preset screen` 과 `hanlint sheet apply 시트.md`. 뜻은 파이썬 cli/commands/sheet.py 가 소유한다. @param {string[]} args */
1049
+ function runSheet(args) {
1050
+ const { options, positionals } = parseArgs(args);
1051
+ const output = /** @type {string | undefined} */ (options["--output"]);
1052
+ if (!positionals.length) throw new UsageError("hanlint sheet 폴더/ 또는 hanlint sheet apply 시트.md");
1053
+ if (positionals[0] === "apply") {
1054
+ if (positionals.length !== 2) {
1055
+ emit("hanlint sheet apply 시트.md 꼴로 시트 하나를 준다", output);
1056
+ return 2;
1057
+ }
1058
+ const dryRun = Boolean(options["--dry-run"]);
1059
+ const [applied, failed] = applySheet(positionals[1], dryRun);
1060
+ const lines = [`${dryRun ? "볼 것" : "적용"} ${applied.length}건, 실패 ${failed.length}건`];
1061
+ lines.push(...applied.map((item) => ` ${item}`));
1062
+ lines.push(...failed.map((item) => ` 실패 ${item}`));
1063
+ emit(lines.join("\n"), output);
1064
+ return failed.length ? 1 : 0;
1065
+ }
1066
+ /** @type {string[]} */
1067
+ const files = [];
1068
+ for (const target of positionals) {
1069
+ if (!existsSync(target)) throw new Error(`${target} 이 없다`);
1070
+ if (statSync(target).isDirectory()) files.push(...sourceUnder(target));
1071
+ else files.push(target);
1072
+ }
1073
+ const config = configFrom(options, files);
1074
+ const format = choose(/** @type {string} */ (options["--format"] ?? "markdown"), ["markdown", "json"], "--format");
1075
+ /** @type {import("../report/sheet.js").SheetRow[]} */
1076
+ const rows = [];
1077
+ for (const file of files) {
1078
+ const label = relativeLabel(file);
1079
+ for (const literal of sourceLiterals(readFileSync(file, "utf-8"), label)) {
1080
+ const findings = runAll(fingerprint(literal.plain, config), config).filter((finding) => finding.severity === "error");
1081
+ if (findings.length || options["--all"]) rows.push({ file: label, line: literal.line, text: literal.text, findings, fix: "" });
1082
+ }
1083
+ }
1084
+ emit(format === "json" ? renderSheetJson(rows, config.preset, files.length) : renderSheet(rows, config.preset, files.length), output);
1085
+ return 0;
1086
+ }
1087
+
985
1088
  /** @param {string[]} argv */
986
1089
  function dispatch(argv) {
987
1090
  if (!argv.length) {
@@ -1014,6 +1117,7 @@ function dispatch(argv) {
1014
1117
  if (command === "hook") return runHook(rest);
1015
1118
  if (command === "baseline") return runBaseline(rest);
1016
1119
  if (command === "doctor") return runDoctor(rest);
1120
+ if (command === "sheet") return runSheet(rest);
1017
1121
  return runInit(rest);
1018
1122
  }
1019
1123
 
@@ -0,0 +1,143 @@
1
+ // @ts-check
2
+ /**
3
+ * 소스 파일의 글 마디. 사람이 읽는 한국어 문자열 리터럴과 JSX 글을 줄 번호와 함께 뽑는다.
4
+ * 뜻과 규칙은 파이썬 document/sourceText.py 가 소유한다. 여기는 같은 값을 낸다.
5
+ */
6
+
7
+ export const SOURCE_SUFFIXES = [".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx", ".rs", ".py"];
8
+
9
+ const KOREAN = /[가-힣]/;
10
+ // 문자열의 경계. 줄을 앞에서부터 훑어 여는 따옴표에서 같은 닫는 따옴표까지를 한 마디로 본다. 파이썬 sourceText.py 와 같다.
11
+ const QUOTES = ["'", '"', "`"];
12
+ const EXPRESSION = /\{[^{}]*\}/g;
13
+ const BLOCK_COMMENT = /\/\*[\s\S]*?\*\//g;
14
+ const DEVELOPER_LINE = /new Error\(|console\.|panic!\(|expect\(|assert/;
15
+ const RUST_CONTINUATION = /\\\r?\n[ \t]*/g;
16
+ const RUST_TEST_MARKER = "#[cfg(test)]";
17
+
18
+ /**
19
+ * @typedef {object} SourceLiteral
20
+ * @property {number} line 1부터 세는 줄 번호. 여러 줄을 이은 러스트 문자열은 시작 줄이다
21
+ * @property {string} text 따옴표와 태그를 뺀 글. 파일에 있는 그대로라 되돌려 쓸 때 찾을 수 있다
22
+ * @property {string} plain 검사에 쓰는 글. 식 (`${...}`, `{...}`) 을 비우고 공백을 하나로 모았다
23
+ */
24
+
25
+ /** 템플릿 리터럴의 `${ ... }` 식을 (중첩 괄호를 세어) 같은 길이의 빈칸으로 바꾼다. @param {string} source */
26
+ export function withoutTemplateExpressions(source) {
27
+ let output = "";
28
+ let index = 0;
29
+ while (index < source.length) {
30
+ if (source[index] === "$" && source[index + 1] === "{") {
31
+ let depth = 0;
32
+ let cursor = index + 1;
33
+ for (; cursor < source.length; cursor++) {
34
+ if (source[cursor] === "{") depth += 1;
35
+ else if (source[cursor] === "}") {
36
+ depth -= 1;
37
+ if (depth === 0) break;
38
+ }
39
+ }
40
+ output += " ".repeat(cursor + 1 - index);
41
+ index = cursor + 1;
42
+ continue;
43
+ }
44
+ output += source[index];
45
+ index += 1;
46
+ }
47
+ return output;
48
+ }
49
+
50
+ /** 주석과 개발자용 줄을 걷어낸 소스. 줄 번호는 유지한다. @param {string} source @param {string} path */
51
+ export function userFacingSource(source, path) {
52
+ const isRust = path.endsWith(".rs");
53
+ let body = source;
54
+ if (isRust && body.includes(RUST_TEST_MARKER)) body = body.slice(0, body.indexOf(RUST_TEST_MARKER));
55
+ if (isRust) body = body.replace(RUST_CONTINUATION, "");
56
+ body = body.replace(BLOCK_COMMENT, (block) => block.replace(/[^\n]/g, " "));
57
+ return body
58
+ .split("\n")
59
+ .map((line) => {
60
+ const trimmed = line.trim();
61
+ if (trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("#")) return "";
62
+ if (DEVELOPER_LINE.test(line)) return "";
63
+ return line.replace(/\/\/.*$/, "");
64
+ })
65
+ .join("\n");
66
+ }
67
+
68
+ /** 검사에 쓰는 글. 식을 비우고 공백을 하나로 모은다. @param {string} text */
69
+ export function plainText(text) {
70
+ let copy = withoutTemplateExpressions(text);
71
+ while (EXPRESSION.test(copy)) copy = copy.replace(EXPRESSION, " ");
72
+ return copy.replace(/\s+/g, " ").trim();
73
+ }
74
+
75
+ /** `start` 의 따옴표를 닫는 자리. 역슬래시 뒤 글자는 건너뛴다. 없으면 -1. @param {string} line @param {number} start */
76
+ function closingQuote(line, start) {
77
+ const quote = line[start];
78
+ let index = start + 1;
79
+ while (index < line.length) {
80
+ const char = line[index];
81
+ if (char === "\\") {
82
+ index += 2;
83
+ continue;
84
+ }
85
+ if (char === quote) return index;
86
+ index += 1;
87
+ }
88
+ return -1;
89
+ }
90
+
91
+ /** 한 줄의 글 마디를 나온 차례로. 뜻은 파이썬 lineLiterals 가 소유한다. @param {string} line @returns {string[]} */
92
+ export function lineLiterals(line) {
93
+ /** @type {string[]} */
94
+ const found = [];
95
+ let index = 0;
96
+ while (index < line.length) {
97
+ const char = line[index];
98
+ if (QUOTES.includes(char)) {
99
+ const end = closingQuote(line, index);
100
+ if (end < 0) break;
101
+ found.push(line.slice(index + 1, end));
102
+ index = end + 1;
103
+ continue;
104
+ }
105
+ if (char === ">" && (index === 0 || !"=-".includes(line[index - 1]))) {
106
+ const end = line.indexOf("<", index + 1);
107
+ if (end > index) {
108
+ const inner = line.slice(index + 1, end);
109
+ found.push(inner);
110
+ if (!inner.includes("{")) {
111
+ index = end;
112
+ continue;
113
+ }
114
+ }
115
+ }
116
+ index += 1;
117
+ }
118
+ return found;
119
+ }
120
+
121
+ /** 소스에서 글 마디를 줄 번호 순으로. @param {string} source @param {string} [path] @returns {SourceLiteral[]} */
122
+ export function sourceLiterals(source, path = "") {
123
+ /** @type {SourceLiteral[]} */
124
+ const found = [];
125
+ userFacingSource(source, path).split("\n").forEach((line, index) => {
126
+ for (const raw of lineLiterals(line)) {
127
+ const text = raw.trim();
128
+ const plain = plainText(text);
129
+ if (text && KOREAN.test(plain)) found.push({ line: index + 1, text, plain });
130
+ }
131
+ });
132
+ return found;
133
+ }
134
+
135
+ /**
136
+ * 한 줄 안에서 글 `old` 를 `new` 로 바꾼다. 몇 번 나왔는지 같이 준다 (한 번일 때만 바꾼 것이 확정이다).
137
+ * @param {string} lineText @param {string} oldText @param {string} newText @returns {[string, number]}
138
+ */
139
+ export function replaceLiteral(lineText, oldText, newText) {
140
+ const count = lineText.split(oldText).length - 1;
141
+ if (count !== 1) return [lineText, count];
142
+ return [lineText.replace(oldText, () => newText), 1];
143
+ }
package/src/index.js CHANGED
@@ -25,6 +25,8 @@ import {
25
25
  import { configFromMapping, defaultConfig } from "./config/settings.js";
26
26
  import { loadVersion } from "./data/load.js";
27
27
  import { parseMarkdown } from "./document/parseMarkdown.js";
28
+ import { SOURCE_SUFFIXES, replaceLiteral, sourceLiterals } from "./document/sourceText.js";
29
+ import { parseSheet, renderSheet, renderSheetJson } from "./report/sheet.js";
28
30
  import { headingsOf } from "./document/model.js";
29
31
  import { applyFixes } from "./edit/applyFixes.js";
30
32
  import { buildFingerprint } from "./fingerprint/build.js";
@@ -48,6 +50,12 @@ export function loadConfig(path = null, start = null) {
48
50
 
49
51
  export {
50
52
  CheckResult,
53
+ SOURCE_SUFFIXES,
54
+ parseSheet,
55
+ renderSheet,
56
+ renderSheetJson,
57
+ replaceLiteral,
58
+ sourceLiterals,
51
59
  CONTRACT_VERSION,
52
60
  CONTRACT_VERSIONS,
53
61
  Contract,
Binary file