@atlisp/lint 0.1.6 → 0.1.8

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
@@ -27,6 +27,12 @@ npx @atlisp/lint --fix
27
27
 
28
28
  # 监听文件变化自动重检
29
29
  npx @atlisp/lint --watch
30
+
31
+ # 自动格式化代码缩进
32
+ npx @atlisp/lint --format-code
33
+
34
+ # 检查代码格式(不修改文件)
35
+ npx @atlisp/lint --format-check
30
36
  ```
31
37
 
32
38
  ## CLI 选项
@@ -39,13 +45,17 @@ npx @atlisp/lint --watch
39
45
  --format <format> 输出格式:default(默认)| json
40
46
  --init 在当前目录生成 atlisp-lint.json
41
47
  --install-hook 安装 git pre-commit hook
42
- --fix 自动修复尾部空格、UTF-8 BOM、多余括号等问题
48
+ --fix 自动修复尾部空格、BOM、多余括号、冗余 progn/let、引号风格等问题
43
49
  --cache 启用文件哈希缓存,跳过未变更的文件
44
50
  --clear-cache 清除缓存目录
45
51
  --parallel 开启多线程并行检查(使用 Worker Threads)
46
52
  --project 启用跨文件分析(Phase 1b)
47
53
  --watch 监听模式,文件变动自动重新检查
48
54
  --hook-args <args> 自定义 pre-commit hook 参数(配合 --install-hook)
55
+ --help 显示帮助信息
56
+ --version 显示版本号
57
+ --format-code 自动格式化代码缩进(基于括号深度 + AST 对齐)
58
+ --format-check 只检查代码缩进格式(不修改文件,不符合则退出码 1)
49
59
  ```
50
60
 
51
61
  ## 架构:三相检测
@@ -120,6 +130,7 @@ npx @atlisp/lint --watch
120
130
  | `cond_simplify` | warn | 风格 | 单分支 cond 可简化为 if |
121
131
  | `arg_count` | warn | 最佳实践 | 参数数量不匹配 |
122
132
  | `bare_function_names` | off | 风格 | defun 缺少命名空间前缀 |
133
+ | `format_indent` | off | 风格 | 代码缩进不符合格式化规范 |
123
134
  | `function_order` | off | 最佳实践 | 定义在使用之后 |
124
135
  | `magic_number` | off | 风格 | 硬编码魔法数字 |
125
136
  | `module_registration` | off | 架构 | 模块文件未注册到 \*modules\* |
@@ -148,6 +159,26 @@ npx @atlisp/lint --watch
148
159
  - CL-ism — `#(`、`#\`、`#.` 等 AutoLISP 无效语法
149
160
  - defmacro — 默认不允许,除非文件在允许列表中
150
161
 
162
+ ## --format-code 代码格式化
163
+
164
+ 基于括号深度自动调整代码缩进,规则:
165
+
166
+ - 每层嵌套增加 2 空格缩进
167
+ - `)` 单独一行时自动回到对应层级的缩进
168
+ - 注释行按当前嵌套深度缩进
169
+ - 字符串中的括号不影响缩进计算
170
+ - 自动删除行尾多余空格
171
+ - 空行保持不变
172
+ - 格式化后进行幂等性检查,若再次格式化结果不一致则输出警告
173
+
174
+ ```bash
175
+ # 格式化所有 .lsp 文件
176
+ npx @atlisp/lint --format-code
177
+
178
+ # 仅检查格式(不修改文件),不符合则退出码 1
179
+ npx @atlisp/lint --format-check
180
+ ```
181
+
151
182
  ## --fix 自动修复
152
183
 
153
184
  ### 预扫描修复(fixFile)
@@ -168,6 +199,12 @@ npx @atlisp/lint --watch
168
199
  | `redundant_nil_else` | (if cond x nil) → (if cond x) |
169
200
  | `single_arg_and_or` | (and x) → x |
170
201
  | `redundant_setq` | 删除 (setq x x) 行 |
202
+ | `redundant_progn` | 移除分支中多余的 (progn ...) 包装 |
203
+ | `redundant_let` | (let () ...) → (progn ...) |
204
+ | `quote_style` | (quote x) → 'x |
205
+ | `redundant_if` | 移除 if/when 分支中冗余 progn |
206
+ | `misplaced_else` | (if (not x) a b) → (if x b a) |
207
+ | `format_indent` | 对整个文件应用 `formatCode()` 修复缩进 |
171
208
 
172
209
  ## 行内忽略
173
210
 
@@ -69,6 +69,7 @@
69
69
  "nth_usage": "warn",
70
70
  "append_single": "warn",
71
71
  "setq_multiple": "warn",
72
+ "format_indent": "off",
72
73
  "function_order": "off",
73
74
  "magic_number": "off",
74
75
  "mixed_indent": "warn",
@@ -0,0 +1,3 @@
1
+ import { Issue } from '../types';
2
+ export declare function checkFormatIndent(content: string, file: string): Issue[];
3
+ //# sourceMappingURL=format-indent.d.ts.map
@@ -0,0 +1,29 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkFormatIndent = checkFormatIndent;
4
+ const formatter_1 = require("../formatter");
5
+ function checkFormatIndent(content, file) {
6
+ const formatted = (0, formatter_1.formatCode)(content);
7
+ if (content === formatted)
8
+ return [];
9
+ // Find first line that differs for better error location
10
+ const originalLines = content.split('\n');
11
+ const formattedLines = formatted.split('\n');
12
+ let firstDiffLine = 1;
13
+ for (let i = 0; i < Math.max(originalLines.length, formattedLines.length); i++) {
14
+ if (originalLines[i] !== formattedLines[i]) {
15
+ firstDiffLine = i + 1;
16
+ break;
17
+ }
18
+ }
19
+ return [
20
+ {
21
+ file,
22
+ line: firstDiffLine,
23
+ severity: 'warn',
24
+ rule: 'format_indent',
25
+ message: 'Code indentation does not match expected format',
26
+ },
27
+ ];
28
+ }
29
+ //# sourceMappingURL=format-indent.js.map
package/dist/config.js CHANGED
@@ -120,6 +120,7 @@ const DEFAULT_CONFIG = {
120
120
  dynamic_doc: 'warn',
121
121
  loop_optimization: 'off',
122
122
  type_check: 'off',
123
+ format_indent: 'off',
123
124
  redundant_if: 'warn',
124
125
  },
125
126
  line_length: {
@@ -0,0 +1,2 @@
1
+ export declare function formatCode(content: string, indentSize?: number): string;
2
+ //# sourceMappingURL=formatter.d.ts.map
@@ -0,0 +1,51 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.formatCode = formatCode;
4
+ const utils_1 = require("./utils");
5
+ function v1Format(content, indentSize) {
6
+ const lines = content.split('\n');
7
+ const result = [];
8
+ let depth = 0;
9
+ for (const line of lines) {
10
+ const trimmedLine = line.trimEnd();
11
+ if (trimmedLine === '') {
12
+ result.push('');
13
+ continue;
14
+ }
15
+ const stripped = (0, utils_1.stripLine)(trimmedLine);
16
+ const trimmedStripped = stripped.trim();
17
+ if (trimmedStripped === '') {
18
+ const indent = ' '.repeat(depth * indentSize);
19
+ result.push(indent + trimmedLine.trimStart());
20
+ continue;
21
+ }
22
+ let openCount = 0;
23
+ let closeCount = 0;
24
+ for (const ch of trimmedStripped) {
25
+ if (ch === '(')
26
+ openCount++;
27
+ else if (ch === ')')
28
+ closeCount++;
29
+ }
30
+ let effectiveDepth = depth;
31
+ if (trimmedStripped.startsWith(')')) {
32
+ let leadingClose = 0;
33
+ for (const ch of trimmedStripped) {
34
+ if (ch === ')')
35
+ leadingClose++;
36
+ else
37
+ break;
38
+ }
39
+ effectiveDepth = Math.max(0, depth - leadingClose);
40
+ }
41
+ result.push(' '.repeat(effectiveDepth * indentSize) + trimmedLine.trimStart());
42
+ depth += openCount - closeCount;
43
+ if (depth < 0)
44
+ depth = 0;
45
+ }
46
+ return result;
47
+ }
48
+ function formatCode(content, indentSize = 2) {
49
+ return v1Format(content, indentSize).join('\n');
50
+ }
51
+ //# sourceMappingURL=formatter.js.map
package/dist/index.js CHANGED
@@ -42,6 +42,7 @@ const runner_1 = require("./runner");
42
42
  const project_1 = require("./project");
43
43
  const sbcl_1 = require("./sbcl");
44
44
  const formatters_1 = require("./formatters");
45
+ const formatter_1 = require("./formatter");
45
46
  const locale_1 = require("./locale");
46
47
  const cache_1 = require("./cache");
47
48
  const watch_1 = require("./watch");
@@ -61,6 +62,11 @@ function parseArgs() {
61
62
  parallel: false,
62
63
  project: false,
63
64
  watch: false,
65
+ help: false,
66
+ version: false,
67
+ formatCode: false,
68
+ formatCheck: false,
69
+ sbcl: false,
64
70
  };
65
71
  for (let i = 0; i < argv.length; i++) {
66
72
  switch (argv[i]) {
@@ -106,6 +112,21 @@ function parseArgs() {
106
112
  case '--watch':
107
113
  opts.watch = true;
108
114
  break;
115
+ case '--help':
116
+ opts.help = true;
117
+ break;
118
+ case '--version':
119
+ opts.version = true;
120
+ break;
121
+ case '--format-code':
122
+ opts.formatCode = true;
123
+ break;
124
+ case '--format-check':
125
+ opts.formatCheck = true;
126
+ break;
127
+ case '--sbcl':
128
+ opts.sbcl = true;
129
+ break;
109
130
  default:
110
131
  break;
111
132
  }
@@ -227,10 +248,27 @@ npx @atlisp/lint ${args} || exit 1
227
248
  fs.chmodSync(hookPath, 0o755);
228
249
  console.log((0, locale_1.t)('index.hook_installed', hookPath));
229
250
  }
251
+ function printHelp() {
252
+ console.log((0, locale_1.t)('help.text'));
253
+ }
230
254
  async function main() {
231
255
  try {
232
256
  const opts = parseArgs();
233
257
  const rootDir = process.cwd();
258
+ // Load config early so locale is available for --help and other commands
259
+ const configPath = opts.config || path.join(rootDir, 'atlisp-lint.json');
260
+ const config = (0, config_1.loadConfig)(fs.existsSync(configPath) ? configPath : undefined);
261
+ (0, locale_1.setLocale)(config.locale || 'zh');
262
+ if (opts.help) {
263
+ printHelp();
264
+ return;
265
+ }
266
+ if (opts.version) {
267
+ const pkgPath = path.join(__dirname, '..', 'package.json');
268
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
269
+ console.log(pkg.version);
270
+ return;
271
+ }
234
272
  if (opts.init) {
235
273
  initConfig(rootDir);
236
274
  return;
@@ -244,16 +282,40 @@ async function main() {
244
282
  console.log((0, locale_1.t)('index.cache_cleared'));
245
283
  return;
246
284
  }
247
- // Load config
248
- const configPath = opts.config || path.join(rootDir, 'atlisp-lint.json');
249
- const config = (0, config_1.loadConfig)(fs.existsSync(configPath) ? configPath : undefined);
250
- (0, locale_1.setLocale)(config.locale || 'zh');
251
285
  // Collect files
252
286
  const files = opts.staged ? collectStagedFiles(rootDir) : collectFiles(rootDir, opts, config);
253
287
  if (files.length === 0) {
254
288
  console.log((0, locale_1.t)('index.no_files'));
255
289
  return;
256
290
  }
291
+ // --format-code mode: format files in place
292
+ if (opts.formatCode || opts.formatCheck) {
293
+ let needsFormat = false;
294
+ for (const f of files) {
295
+ const content = fs.readFileSync(f, 'utf-8');
296
+ const formatted = (0, formatter_1.formatCode)(content);
297
+ if (content !== formatted) {
298
+ if (opts.formatCheck) {
299
+ console.log(`${path.relative(rootDir, f)} — needs formatting`);
300
+ needsFormat = true;
301
+ }
302
+ else {
303
+ fs.writeFileSync(f, formatted, 'utf-8');
304
+ console.log(`${(0, locale_1.t)('summary.tag_fail')}: ${path.relative(rootDir, f)} — ${(0, locale_1.t)('index.fixed', 'format')}`);
305
+ }
306
+ }
307
+ // Idempotency check: format again to verify stability
308
+ if (!opts.formatCheck) {
309
+ const reFormatted = (0, formatter_1.formatCode)(fs.readFileSync(f, 'utf-8'));
310
+ if (reFormatted !== fs.readFileSync(f, 'utf-8')) {
311
+ console.log(`${(0, locale_1.t)('summary.tag_warn')}: ${path.relative(rootDir, f)} — ${(0, locale_1.t)('index.format_unstable')}`);
312
+ }
313
+ }
314
+ }
315
+ if (opts.formatCheck && needsFormat)
316
+ process.exit(1);
317
+ return;
318
+ }
257
319
  // --watch mode
258
320
  if (opts.watch) {
259
321
  (0, watch_1.watchFiles)(files, { rootDir, format: opts.format, config });
@@ -267,6 +329,22 @@ async function main() {
267
329
  console.log(`${(0, locale_1.t)('summary.tag_fail')}: ${path.relative(rootDir, f)} — ${(0, locale_1.t)('index.fixed', rule)}`);
268
330
  }
269
331
  }
332
+ // format_indent fix for non-staged mode (no lint results available yet)
333
+ if (config.checks['format_indent'] !== 'off') {
334
+ for (const f of files) {
335
+ try {
336
+ const content = fs.readFileSync(f, 'utf-8');
337
+ const formatted = (0, formatter_1.formatCode)(content);
338
+ if (formatted !== content) {
339
+ fs.writeFileSync(f, formatted, 'utf-8');
340
+ console.log(`${(0, locale_1.t)('summary.tag_fail')}: ${path.relative(rootDir, f)} — ${(0, locale_1.t)('index.fixed', 'format_indent')}`);
341
+ }
342
+ }
343
+ catch {
344
+ /* ignore */
345
+ }
346
+ }
347
+ }
270
348
  if (!opts.staged)
271
349
  return;
272
350
  }
@@ -322,6 +400,26 @@ async function main() {
322
400
  }
323
401
  }
324
402
  }
403
+ // format_indent fix: apply formatCode to files with format_indent issues
404
+ if (config.checks['format_indent'] !== 'off') {
405
+ for (const f of filesToLint) {
406
+ const relPath = path.relative(rootDir, f);
407
+ const hasFormatIssue = issues.some(i => i.file === relPath && i.rule === 'format_indent');
408
+ if (hasFormatIssue) {
409
+ try {
410
+ const content = fs.readFileSync(f, 'utf-8');
411
+ const formatted = (0, formatter_1.formatCode)(content);
412
+ if (formatted !== content) {
413
+ fs.writeFileSync(f, formatted, 'utf-8');
414
+ console.log(`${(0, locale_1.t)('summary.tag_fail')}: ${relPath} — ${(0, locale_1.t)('index.fixed', 'format_indent')}`);
415
+ }
416
+ }
417
+ catch {
418
+ /* ignore */
419
+ }
420
+ }
421
+ }
422
+ }
325
423
  }
326
424
  // Mark as cached (reuse pre-read content if available)
327
425
  if (opts.cache) {
@@ -329,21 +427,23 @@ async function main() {
329
427
  (0, cache_1.markCached)(f, rootDir, contentCache.get(f));
330
428
  }
331
429
  }
332
- // Phase 2: SBCL syntax validation
333
- const stubPkgPath = path.join(__dirname, '..', 'stub-packages.json');
334
- try {
335
- const sbclIssues = (0, sbcl_1.runSbclLint)(rootDir, config.sbcl, stubPkgPath);
336
- issues.push(...sbclIssues);
337
- }
338
- catch (err) {
339
- const msg = err instanceof Error ? err.message : String(err);
340
- issues.push({
341
- file: 'sbcl',
342
- line: 1,
343
- severity: 'error',
344
- rule: 'sbcl',
345
- message: msg,
346
- });
430
+ // Phase 2: SBCL syntax validation (only when --sbcl is specified)
431
+ if (opts.sbcl) {
432
+ const stubPkgPath = path.join(__dirname, '..', 'stub-packages.json');
433
+ try {
434
+ const sbclIssues = (0, sbcl_1.runSbclLint)(rootDir, config.sbcl, stubPkgPath);
435
+ issues.push(...sbclIssues);
436
+ }
437
+ catch (err) {
438
+ const msg = err instanceof Error ? err.message : String(err);
439
+ issues.push({
440
+ file: 'sbcl',
441
+ line: 1,
442
+ severity: 'error',
443
+ rule: 'sbcl',
444
+ message: msg,
445
+ });
446
+ }
347
447
  }
348
448
  // Build file content map for code line display
349
449
  const fileContents = new Map();
package/dist/locale.js CHANGED
@@ -55,6 +55,7 @@ const messages = {
55
55
  error_handling: "Functions using command but missing *error*: '{0}'",
56
56
  global_naming: "Global variable '{0}' should use *...* naming convention",
57
57
  extra_parens: 'Suspicious excessive closing parenthesis: {0} extra )',
58
+ format_indent: 'Code indentation does not match expected format',
58
59
  setq_single_arg: "Variable '{0}' is setq'd with no value — likely a typo",
59
60
  assoc_without_cdr: 'assoc result is not used — wrap with cdr to extract the value',
60
61
  identical_branches: 'if has identical then and else branches — possible copy-paste error',
@@ -86,6 +87,31 @@ const messages = {
86
87
  'index.watching': 'Watching',
87
88
  'index.watch_hint': 'Press Ctrl+C to stop',
88
89
  'index.watch_status': '{0} files checked: {1} error(s), {2} warning(s)',
90
+ 'index.format_unstable': 'Formatting is not idempotent — re-running formatCode produces different output',
91
+ 'help.text': `@atlisp/lint - AutoLISP static analysis tool
92
+
93
+ Usage: atlisp-lint [options]
94
+
95
+ Options:
96
+ --src <dir> Specify source directory (may be repeated)
97
+ --test <dir> Specify test directory (may be repeated)
98
+ --config <path> Path to config file
99
+ --staged Only check git staged .lsp files
100
+ --format <format> Output format: default | json
101
+ --init Generate atlisp-lint.json in current directory
102
+ --install-hook Install git pre-commit hook
103
+ --fix Auto-fix trailing whitespace, BOM, trailing parens, etc.
104
+ --cache Enable file hash caching, skip unchanged files
105
+ --clear-cache Clear cache directory
106
+ --parallel Enable multi-threaded parallel checking
107
+ --project Enable cross-file analysis (Phase 1b)
108
+ --sbcl Enable SBCL syntax validation (Phase 2)
109
+ --watch Watch mode: re-check on file changes
110
+ --hook-args <args> Custom pre-commit hook arguments (with --install-hook)
111
+ --help Show this help message
112
+ --version Show version number
113
+ --format-code Auto-format code indentation
114
+ --format-check Check if files are formatted (exit 1 if not)`,
89
115
  },
90
116
  zh: {
91
117
  'parens.mismatch': "括号不匹配:{0} 个 '(' vs {1} 个 ')'({2})",
@@ -138,6 +164,7 @@ const messages = {
138
164
  error_handling: "使用 command 但缺少 *error* 的函数: '{0}'",
139
165
  global_naming: "全局变量 '{0}' 应使用 *...* 命名约定",
140
166
  extra_parens: '可疑的过多闭合括号:多余 {0} 个 )',
167
+ format_indent: '代码缩进不符合格式化规范',
141
168
  setq_single_arg: "变量 '{0}' 被 setq 但未赋值——可能是笔误",
142
169
  assoc_without_cdr: 'assoc 的结果未被使用——应使用 cdr 获取值',
143
170
  identical_branches: 'if 的 then 和 else 分支完全相同——可能是复制粘贴错误',
@@ -169,6 +196,31 @@ const messages = {
169
196
  'index.watching': '监听中',
170
197
  'index.watch_hint': '按 Ctrl+C 停止',
171
198
  'index.watch_status': '已检查 {0} 个文件: {1} 个错误, {2} 个警告',
199
+ 'index.format_unstable': '格式化结果不稳定——再次格式化后输出不同',
200
+ 'help.text': `@atlisp/lint - AutoLISP 静态分析工具
201
+
202
+ 用法: atlisp-lint [选项]
203
+
204
+ 选项:
205
+ --src <目录> 指定源代码目录(可重复)
206
+ --test <目录> 指定测试目录(可重复)
207
+ --config <路径> 配置文件路径
208
+ --staged 仅检查 git 暂存区的 .lsp 文件
209
+ --format <格式> 输出格式:default | json
210
+ --init 在当前目录生成 atlisp-lint.json
211
+ --install-hook 安装 git pre-commit 钩子
212
+ --fix 自动修复尾部空格、BOM、尾部括号等
213
+ --cache 启用文件哈希缓存,跳过未变更的文件
214
+ --clear-cache 清除缓存目录
215
+ --parallel 启用多线程并行检查
216
+ --project 启用跨文件分析(Phase 1b)
217
+ --sbcl 启用 SBCL 语法验证(Phase 2)
218
+ --watch 监视模式:文件变更时重新检查
219
+ --hook-args <参数> 自定义 pre-commit 钩子参数(与 --install-hook 配合使用)
220
+ --help 显示此帮助信息
221
+ --version 显示版本号
222
+ --format-code 自动格式化代码缩进
223
+ --format-check 检查文件是否已格式化(未格式化则退出码为 1)`,
172
224
  },
173
225
  };
174
226
  let currentLocale = 'zh';
package/dist/presets.js CHANGED
@@ -141,6 +141,7 @@ exports.PRESETS = {
141
141
  dynamic_doc: 'error',
142
142
  loop_optimization: 'warn',
143
143
  type_check: 'warn',
144
+ format_indent: 'warn',
144
145
  redundant_if: 'error',
145
146
  module_registration: 'error',
146
147
  namespace_header: 'error',
package/dist/rules.js CHANGED
@@ -82,6 +82,7 @@ exports.RULES = [
82
82
  category: '复杂度',
83
83
  },
84
84
  { name: 'function_order', defaultSeverity: 'off', description: '检测函数定义在使用之后', category: '风格' },
85
+ { name: 'format_indent', defaultSeverity: 'off', description: '检测代码缩进是否符合格式化规范', category: '风格' },
85
86
  {
86
87
  name: 'identical_branches',
87
88
  defaultSeverity: 'warn',
package/dist/runner.js CHANGED
@@ -111,6 +111,7 @@ const dynamic_doc_1 = require("./checks/dynamic-doc");
111
111
  const loop_optimization_1 = require("./checks/loop-optimization");
112
112
  const type_check_1 = require("./checks/type-check");
113
113
  const redundant_if_1 = require("./checks/redundant-if");
114
+ const format_indent_1 = require("./checks/format-indent");
114
115
  const config_1 = require("./config");
115
116
  const locale_1 = require("./locale");
116
117
  const disable_1 = require("./disable");
@@ -158,7 +159,7 @@ function runChecks(content, file, config) {
158
159
  }
159
160
  function addIfEnabled(rule, fn) {
160
161
  const severity = config.checks[rule];
161
- if (severity === 'off')
162
+ if (!severity || severity === 'off')
162
163
  return;
163
164
  const results = fn();
164
165
  for (const r of results) {
@@ -234,6 +235,7 @@ function runChecks(content, file, config) {
234
235
  addIfEnabled('loop_optimization', () => (0, loop_optimization_1.checkLoopOptimization)(content, file));
235
236
  addIfEnabled('type_check', () => (0, type_check_1.checkTypeCheck)(content, file));
236
237
  addIfEnabled('redundant_if', () => (0, redundant_if_1.checkRedundantIf)(content, file));
238
+ addIfEnabled('format_indent', () => (0, format_indent_1.checkFormatIndent)(content, file));
237
239
  return issues;
238
240
  }
239
241
  /** Single-pass visitor-based runChecks using AstVisitor */
@@ -413,6 +415,47 @@ function lintFilesParallel(files, config, rootDir) {
413
415
  resolve([]);
414
416
  });
415
417
  }
418
+ /** Find the matching close paren for the first complete form starting at `start` */
419
+ function findMatchingParen(s, start) {
420
+ if (s[start] !== '(')
421
+ return start;
422
+ let depth = 0;
423
+ for (let i = start; i < s.length; i++) {
424
+ if (s[i] === '"') {
425
+ i++;
426
+ while (i < s.length && s[i] !== '"') {
427
+ if (s[i] === '\\')
428
+ i++;
429
+ i++;
430
+ }
431
+ continue;
432
+ }
433
+ if (s[i] === '(')
434
+ depth++;
435
+ else if (s[i] === ')') {
436
+ depth--;
437
+ if (depth === 0)
438
+ return i;
439
+ }
440
+ }
441
+ return s.length - 1;
442
+ }
443
+ /** Extract the next top-level form(s) from a string starting at `start`. Returns [form, endIdx]. */
444
+ function extractForm(s, start) {
445
+ let i = start;
446
+ while (i < s.length && s[i] === ' ')
447
+ i++;
448
+ if (i >= s.length)
449
+ return ['', i];
450
+ if (s[i] !== '(') {
451
+ const m = s.slice(i).match(/^\S+/);
452
+ if (m)
453
+ return [m[0], i + m[0].length];
454
+ return ['', i];
455
+ }
456
+ const end = findMatchingParen(s, i);
457
+ return [s.slice(i, end + 1), end + 1];
458
+ }
416
459
  function applyFixes(issues, content, filepath) {
417
460
  const lines = content.split('\n');
418
461
  const fixesByRule = new Map();
@@ -430,6 +473,11 @@ function applyFixes(issues, content, filepath) {
430
473
  'redundant_nil_else',
431
474
  'single_arg_and_or',
432
475
  'redundant_setq',
476
+ 'redundant_progn',
477
+ 'redundant_let',
478
+ 'quote_style',
479
+ 'redundant_if',
480
+ 'misplaced_else',
433
481
  ]);
434
482
  let result = content;
435
483
  for (const [rule, lineSet] of fixesByRule) {
@@ -474,6 +522,88 @@ function applyFixes(issues, content, filepath) {
474
522
  }
475
523
  break;
476
524
  }
525
+ case 'redundant_progn': {
526
+ // (progn <single-form>) → <single-form>
527
+ const proIdx = lineContent.indexOf('(progn ');
528
+ if (proIdx !== -1) {
529
+ const beforePro = lineContent.slice(0, proIdx);
530
+ const close = findMatchingParen(lineContent, proIdx);
531
+ if (close > proIdx) {
532
+ const inner = lineContent.slice(proIdx + 7, close);
533
+ const rest = lineContent.slice(close + 1);
534
+ const fixed = beforePro + inner + rest;
535
+ if (fixed !== lineContent)
536
+ result = replaceLine(result, idx, fixed);
537
+ }
538
+ }
539
+ break;
540
+ }
541
+ case 'redundant_let': {
542
+ // (let () body...) → (progn body...)
543
+ const fixed = lineContent.replace(/\(let\s+\(\)\s*/, '(progn ');
544
+ if (fixed !== lineContent)
545
+ result = replaceLine(result, idx, fixed);
546
+ break;
547
+ }
548
+ case 'quote_style': {
549
+ // (quote x) → 'x
550
+ const qIdx = lineContent.indexOf('(quote ');
551
+ if (qIdx !== -1) {
552
+ const beforeQ = lineContent.slice(0, qIdx);
553
+ const close = findMatchingParen(lineContent, qIdx);
554
+ if (close > qIdx) {
555
+ const quoted = lineContent.slice(qIdx + 7, close);
556
+ const rest = lineContent.slice(close + 1);
557
+ const fixed = beforeQ + "'" + quoted + rest;
558
+ if (fixed !== lineContent)
559
+ result = replaceLine(result, idx, fixed);
560
+ }
561
+ }
562
+ break;
563
+ }
564
+ case 'redundant_if': {
565
+ // (if/when/unless (progn ...) ...) → remove inner progn
566
+ const pIdx = lineContent.indexOf('(progn ');
567
+ if (pIdx !== -1) {
568
+ const beforeP = lineContent.slice(0, pIdx);
569
+ const close = findMatchingParen(lineContent, pIdx);
570
+ if (close > pIdx) {
571
+ const inner = lineContent.slice(pIdx + 7, close);
572
+ const rest = lineContent.slice(close + 1);
573
+ const fixed = beforeP + inner + rest;
574
+ if (fixed !== lineContent)
575
+ result = replaceLine(result, idx, fixed);
576
+ }
577
+ }
578
+ break;
579
+ }
580
+ case 'misplaced_else': {
581
+ // (if (not x) then else) → (if x else then)
582
+ const ifStart = lineContent.indexOf('(if (not ');
583
+ if (ifStart !== -1) {
584
+ const notIdx = lineContent.indexOf('(not ', ifStart);
585
+ if (notIdx === -1)
586
+ break;
587
+ const notClose = findMatchingParen(lineContent, notIdx);
588
+ if (notClose <= notIdx)
589
+ break;
590
+ const ifClose = findMatchingParen(lineContent, ifStart);
591
+ if (ifClose <= ifStart)
592
+ break;
593
+ const indent = lineContent.slice(0, ifStart);
594
+ const condVar = lineContent.slice(notIdx + 5, notClose);
595
+ const branchContent = lineContent.slice(notClose + 1, ifClose).trim();
596
+ const [thenForm, thenEnd] = extractForm(branchContent, 0);
597
+ const elseForm = branchContent.slice(thenEnd).trim();
598
+ if (thenForm && elseForm) {
599
+ const rest = lineContent.slice(ifClose + 1);
600
+ const fixed = indent + '(if ' + condVar + ' ' + elseForm + ' ' + thenForm + ')' + rest;
601
+ if (fixed !== lineContent)
602
+ result = replaceLine(result, idx, fixed);
603
+ }
604
+ }
605
+ break;
606
+ }
477
607
  case 'redundant_setq': {
478
608
  // Remove (setq x x) lines
479
609
  const allLines = result.split('\n');
package/dist/validate.js CHANGED
@@ -24,6 +24,7 @@ const VALID_RULES = [
24
24
  'eq_usage',
25
25
  'error_handling',
26
26
  'extra_parens',
27
+ 'format_indent',
27
28
  'function_complexity',
28
29
  'function_order',
29
30
  'global_naming',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlisp/lint",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "AutoLISP static analysis tool — parens, security, conventions, SBCL syntax validation",
5
5
  "keywords": [
6
6
  "CAD",